create-ironclaws 1.0.4 → 1.1.0
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.
- package/package.json +1 -1
- package/template/CLAUDE.md +105 -51
- package/template/container/Dockerfile.argus +14 -12
- package/template/container/skills/slack-formatting/SKILL.md +46 -47
- package/template/container/tools/README.md +33 -0
- package/template/docs/how-it-works.md +19 -19
- package/template/groups/forge/CLAUDE.md +13 -9
- package/template/scripts/setup-onecli-secrets.sh +25 -87
- package/template/src/container-runner.ts +4 -4
- package/template/src/index.ts +3 -3
- package/template/container/tools/elastic_query.py +0 -161
- package/template/container/tools/gdrive_tool.py +0 -185
- package/template/container/tools/jira_tool.py +0 -433
- package/template/container/tools/slack_history_tool.py +0 -144
- package/template/container/tools/youtube_tool.py +0 -174
- package/template/scripts/register-expense-agent.sh +0 -121
- package/template/setup-agents.sh +0 -142
|
@@ -1,433 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Jira tool for Argus agents.
|
|
3
|
-
|
|
4
|
-
Usage:
|
|
5
|
-
# Get next ticket to work on (highest priority, oldest first)
|
|
6
|
-
python3 jira_tool.py next
|
|
7
|
-
|
|
8
|
-
# Get specific ticket
|
|
9
|
-
python3 jira_tool.py get --issue IAI-42
|
|
10
|
-
|
|
11
|
-
# Search tickets
|
|
12
|
-
python3 jira_tool.py search --status "To Do"
|
|
13
|
-
python3 jira_tool.py search --status "In Progress" --assignee "user@example.com"
|
|
14
|
-
|
|
15
|
-
# Create a ticket
|
|
16
|
-
python3 jira_tool.py create --summary "Bug in BobAIA" --description "..." --type Bug
|
|
17
|
-
python3 jira_tool.py create --summary "Fix login" --type Bug --epic IAI-10
|
|
18
|
-
|
|
19
|
-
# Create an epic
|
|
20
|
-
python3 jira_tool.py create-epic --summary "Salesforce Integration" --description "..."
|
|
21
|
-
python3 jira_tool.py create-epic --summary "Salesforce Integration" --start-date 2026-04-15 --due-date 2026-06-30
|
|
22
|
-
|
|
23
|
-
# Search epics
|
|
24
|
-
python3 jira_tool.py search-epics
|
|
25
|
-
python3 jira_tool.py search-epics --query "Salesforce"
|
|
26
|
-
|
|
27
|
-
# Update a ticket
|
|
28
|
-
python3 jira_tool.py update --issue IAI-42 --status "In Progress"
|
|
29
|
-
python3 jira_tool.py update --issue IAI-42 --description "Updated description"
|
|
30
|
-
python3 jira_tool.py update --issue IAI-42 --comment "Working on this now"
|
|
31
|
-
python3 jira_tool.py update --issue IAI-42 --assignee-email "user@yourcompany.com"
|
|
32
|
-
|
|
33
|
-
# Find Jira account ID by email
|
|
34
|
-
python3 jira_tool.py user --email "user@yourcompany.com"
|
|
35
|
-
|
|
36
|
-
Environment variables (required):
|
|
37
|
-
JIRA_BASE_URL e.g. https://yourcompany.atlassian.net
|
|
38
|
-
JIRA_EMAIL Atlassian account email
|
|
39
|
-
JIRA_API_TOKEN Atlassian API token
|
|
40
|
-
JIRA_PROJECT_KEY e.g. IAI
|
|
41
|
-
"""
|
|
42
|
-
|
|
43
|
-
import argparse
|
|
44
|
-
import json
|
|
45
|
-
import os
|
|
46
|
-
import sys
|
|
47
|
-
|
|
48
|
-
try:
|
|
49
|
-
import requests
|
|
50
|
-
from requests.auth import HTTPBasicAuth
|
|
51
|
-
except ImportError:
|
|
52
|
-
print("ERROR: 'requests' is not installed. Run: pip3 install requests", file=sys.stderr)
|
|
53
|
-
sys.exit(1)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
PRIORITY_ORDER = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Lowest": 4}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def get_client():
|
|
60
|
-
base_url = os.environ.get("JIRA_BASE_URL", "").rstrip("/")
|
|
61
|
-
project = os.environ.get("JIRA_PROJECT_KEY", "")
|
|
62
|
-
|
|
63
|
-
if not base_url or not project:
|
|
64
|
-
print(
|
|
65
|
-
"ERROR: JIRA_BASE_URL and JIRA_PROJECT_KEY must be set.",
|
|
66
|
-
file=sys.stderr,
|
|
67
|
-
)
|
|
68
|
-
sys.exit(1)
|
|
69
|
-
|
|
70
|
-
email = os.environ.get("JIRA_EMAIL", "")
|
|
71
|
-
token = os.environ.get("JIRA_API_TOKEN", "")
|
|
72
|
-
# auth is optional — if absent, OneCLI HTTPS proxy injects Authorization header.
|
|
73
|
-
auth = HTTPBasicAuth(email, token) if email and token else None
|
|
74
|
-
return base_url, auth, project
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def jira_get(base_url, auth, path, params=None):
|
|
78
|
-
resp = requests.get(
|
|
79
|
-
f"{base_url}/rest/api/3{path}",
|
|
80
|
-
auth=auth,
|
|
81
|
-
headers={"Accept": "application/json"},
|
|
82
|
-
params=params,
|
|
83
|
-
timeout=30,
|
|
84
|
-
)
|
|
85
|
-
resp.raise_for_status()
|
|
86
|
-
return resp.json()
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def jira_search(base_url, auth, jql, max_results=50, fields=None):
|
|
90
|
-
"""POST-based search — returns the issues list directly."""
|
|
91
|
-
if fields is None:
|
|
92
|
-
fields = ["summary", "status", "priority", "issuetype",
|
|
93
|
-
"assignee", "reporter", "description", "created"]
|
|
94
|
-
body = {"jql": jql, "maxResults": max_results, "fields": fields}
|
|
95
|
-
resp = requests.post(
|
|
96
|
-
f"{base_url}/rest/api/3/search/jql",
|
|
97
|
-
auth=auth,
|
|
98
|
-
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
99
|
-
json=body,
|
|
100
|
-
timeout=30,
|
|
101
|
-
)
|
|
102
|
-
resp.raise_for_status()
|
|
103
|
-
return resp.json().get("issues", [])
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def jira_post(base_url, auth, path, body):
|
|
107
|
-
resp = requests.post(
|
|
108
|
-
f"{base_url}/rest/api/3{path}",
|
|
109
|
-
auth=auth,
|
|
110
|
-
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
111
|
-
json=body,
|
|
112
|
-
timeout=30,
|
|
113
|
-
)
|
|
114
|
-
resp.raise_for_status()
|
|
115
|
-
return resp.json()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def jira_put(base_url, auth, path, body):
|
|
119
|
-
resp = requests.put(
|
|
120
|
-
f"{base_url}/rest/api/3{path}",
|
|
121
|
-
auth=auth,
|
|
122
|
-
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
123
|
-
json=body,
|
|
124
|
-
timeout=30,
|
|
125
|
-
)
|
|
126
|
-
resp.raise_for_status()
|
|
127
|
-
return resp
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def jira_transition(base_url, auth, issue_key, status_name):
|
|
131
|
-
"""Move an issue to a new status by name."""
|
|
132
|
-
transitions = jira_get(base_url, auth, f"/issue/{issue_key}/transitions")
|
|
133
|
-
match = next(
|
|
134
|
-
(t for t in transitions["transitions"] if t["name"].lower() == status_name.lower()),
|
|
135
|
-
None,
|
|
136
|
-
)
|
|
137
|
-
if not match:
|
|
138
|
-
available = [t["name"] for t in transitions["transitions"]]
|
|
139
|
-
print(
|
|
140
|
-
f"ERROR: Status '{status_name}' not found. Available: {available}",
|
|
141
|
-
file=sys.stderr,
|
|
142
|
-
)
|
|
143
|
-
sys.exit(1)
|
|
144
|
-
jira_post(base_url, auth, f"/issue/{issue_key}/transitions", {"transition": {"id": match["id"]}})
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def format_issue(issue):
|
|
148
|
-
fields = issue.get("fields", {})
|
|
149
|
-
priority = (fields.get("priority") or {}).get("name", "None")
|
|
150
|
-
status = (fields.get("status") or {}).get("name", "?")
|
|
151
|
-
assignee = (fields.get("assignee") or {}).get("emailAddress", "unassigned")
|
|
152
|
-
reporter = (fields.get("reporter") or {}).get("emailAddress", "?")
|
|
153
|
-
issue_type = (fields.get("issuetype") or {}).get("name", "?")
|
|
154
|
-
summary = fields.get("summary", "?")
|
|
155
|
-
key = issue.get("key", "?")
|
|
156
|
-
|
|
157
|
-
# Extract plain text from description (Atlassian Document Format)
|
|
158
|
-
desc_raw = fields.get("description") or {}
|
|
159
|
-
description = extract_adf_text(desc_raw)
|
|
160
|
-
|
|
161
|
-
return {
|
|
162
|
-
"key": key,
|
|
163
|
-
"type": issue_type,
|
|
164
|
-
"status": status,
|
|
165
|
-
"priority": priority,
|
|
166
|
-
"summary": summary,
|
|
167
|
-
"description": description,
|
|
168
|
-
"reporter": reporter,
|
|
169
|
-
"assignee": assignee,
|
|
170
|
-
"url": f"{os.environ.get('JIRA_BASE_URL', '').rstrip('/')}/browse/{key}",
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def extract_adf_text(node):
|
|
175
|
-
"""Extract plain text from Atlassian Document Format (ADF) JSON."""
|
|
176
|
-
if not node:
|
|
177
|
-
return ""
|
|
178
|
-
if isinstance(node, str):
|
|
179
|
-
return node
|
|
180
|
-
if node.get("type") == "text":
|
|
181
|
-
return node.get("text", "")
|
|
182
|
-
parts = []
|
|
183
|
-
for child in node.get("content", []):
|
|
184
|
-
parts.append(extract_adf_text(child))
|
|
185
|
-
return " ".join(p for p in parts if p).strip()
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def cmd_get(args):
|
|
189
|
-
base_url, auth, _ = get_client()
|
|
190
|
-
issue = jira_get(base_url, auth, f"/issue/{args.issue}")
|
|
191
|
-
print(json.dumps(format_issue(issue), indent=2))
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def cmd_next(args):
|
|
195
|
-
base_url, auth, project = get_client()
|
|
196
|
-
jql = f'project = {project} AND status = "To Do" ORDER BY priority ASC, created ASC'
|
|
197
|
-
issues = jira_search(base_url, auth, jql, max_results=10)
|
|
198
|
-
if not issues:
|
|
199
|
-
print("No tickets in To Do.")
|
|
200
|
-
return
|
|
201
|
-
def sort_key(i):
|
|
202
|
-
p = (i.get("fields", {}).get("priority") or {}).get("name", "Low")
|
|
203
|
-
return (PRIORITY_ORDER.get(p, 99), i.get("fields", {}).get("created", ""))
|
|
204
|
-
issues.sort(key=sort_key)
|
|
205
|
-
print(json.dumps(format_issue(issues[0]), indent=2))
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def cmd_search(args):
|
|
209
|
-
base_url, auth, project = get_client()
|
|
210
|
-
conditions = [f"project = {project}"]
|
|
211
|
-
if args.status:
|
|
212
|
-
conditions.append(f'status = "{args.status}"')
|
|
213
|
-
if args.assignee:
|
|
214
|
-
conditions.append(f'assignee = "{args.assignee}"')
|
|
215
|
-
jql = " AND ".join(conditions) + " ORDER BY priority ASC, created ASC"
|
|
216
|
-
issues = jira_search(base_url, auth, jql)
|
|
217
|
-
print(json.dumps([format_issue(i) for i in issues], indent=2))
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def cmd_create(args):
|
|
221
|
-
base_url, auth, project = get_client()
|
|
222
|
-
|
|
223
|
-
# Build description in ADF format
|
|
224
|
-
description_adf = {
|
|
225
|
-
"type": "doc",
|
|
226
|
-
"version": 1,
|
|
227
|
-
"content": [
|
|
228
|
-
{
|
|
229
|
-
"type": "paragraph",
|
|
230
|
-
"content": [{"type": "text", "text": args.description or ""}],
|
|
231
|
-
}
|
|
232
|
-
],
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
body = {
|
|
236
|
-
"fields": {
|
|
237
|
-
"project": {"key": project},
|
|
238
|
-
"summary": args.summary,
|
|
239
|
-
"description": description_adf,
|
|
240
|
-
"issuetype": {"name": args.type or "Task"},
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
# Set reporter by account ID if provided
|
|
245
|
-
if args.reporter_account_id:
|
|
246
|
-
body["fields"]["reporter"] = {"id": args.reporter_account_id}
|
|
247
|
-
|
|
248
|
-
# Link to parent epic if provided
|
|
249
|
-
if args.epic:
|
|
250
|
-
body["fields"]["parent"] = {"key": args.epic}
|
|
251
|
-
|
|
252
|
-
result = jira_post(base_url, auth, "/issue", body)
|
|
253
|
-
key = result.get("key")
|
|
254
|
-
url = f"{base_url}/browse/{key}"
|
|
255
|
-
print(json.dumps({"key": key, "url": url, "epic": args.epic or None}))
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def cmd_create_epic(args):
|
|
259
|
-
base_url, auth, project = get_client()
|
|
260
|
-
|
|
261
|
-
description_adf = {
|
|
262
|
-
"type": "doc",
|
|
263
|
-
"version": 1,
|
|
264
|
-
"content": [
|
|
265
|
-
{
|
|
266
|
-
"type": "paragraph",
|
|
267
|
-
"content": [{"type": "text", "text": args.description or ""}],
|
|
268
|
-
}
|
|
269
|
-
],
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
body = {
|
|
273
|
-
"fields": {
|
|
274
|
-
"project": {"key": project},
|
|
275
|
-
"summary": args.summary,
|
|
276
|
-
"description": description_adf,
|
|
277
|
-
"issuetype": {"name": "Epic"},
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if args.reporter_account_id:
|
|
282
|
-
body["fields"]["reporter"] = {"id": args.reporter_account_id}
|
|
283
|
-
|
|
284
|
-
if args.start_date:
|
|
285
|
-
# customfield_10015 is the standard Epic start date in Jira Cloud
|
|
286
|
-
body["fields"]["customfield_10015"] = args.start_date
|
|
287
|
-
|
|
288
|
-
if args.due_date:
|
|
289
|
-
body["fields"]["duedate"] = args.due_date
|
|
290
|
-
|
|
291
|
-
result = jira_post(base_url, auth, "/issue", body)
|
|
292
|
-
key = result.get("key")
|
|
293
|
-
url = f"{base_url}/browse/{key}"
|
|
294
|
-
print(json.dumps({"key": key, "url": url, "type": "Epic"}))
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
def cmd_search_epics(args):
|
|
298
|
-
base_url, auth, project = get_client()
|
|
299
|
-
conditions = [f"project = {project}", "issuetype = Epic"]
|
|
300
|
-
if args.query:
|
|
301
|
-
# Escape quotes in query for JQL
|
|
302
|
-
safe_query = args.query.replace('"', '\\"')
|
|
303
|
-
conditions.append(f'summary ~ "{safe_query}"')
|
|
304
|
-
jql = " AND ".join(conditions) + " ORDER BY created DESC"
|
|
305
|
-
issues = jira_search(base_url, auth, jql)
|
|
306
|
-
print(json.dumps([format_issue(i) for i in issues], indent=2))
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def cmd_update(args):
|
|
310
|
-
base_url, auth, _ = get_client()
|
|
311
|
-
|
|
312
|
-
if args.status:
|
|
313
|
-
jira_transition(base_url, auth, args.issue, args.status)
|
|
314
|
-
print(f"Moved {args.issue} to '{args.status}'")
|
|
315
|
-
|
|
316
|
-
if args.description:
|
|
317
|
-
description_adf = {
|
|
318
|
-
"type": "doc",
|
|
319
|
-
"version": 1,
|
|
320
|
-
"content": [
|
|
321
|
-
{
|
|
322
|
-
"type": "paragraph",
|
|
323
|
-
"content": [{"type": "text", "text": args.description}],
|
|
324
|
-
}
|
|
325
|
-
],
|
|
326
|
-
}
|
|
327
|
-
jira_put(base_url, auth, f"/issue/{args.issue}", {"fields": {"description": description_adf}})
|
|
328
|
-
print(f"Updated description on {args.issue}")
|
|
329
|
-
|
|
330
|
-
if args.comment:
|
|
331
|
-
comment_adf = {
|
|
332
|
-
"type": "doc",
|
|
333
|
-
"version": 1,
|
|
334
|
-
"content": [
|
|
335
|
-
{
|
|
336
|
-
"type": "paragraph",
|
|
337
|
-
"content": [{"type": "text", "text": args.comment}],
|
|
338
|
-
}
|
|
339
|
-
],
|
|
340
|
-
}
|
|
341
|
-
jira_post(base_url, auth, f"/issue/{args.issue}/comment", {"body": comment_adf})
|
|
342
|
-
print(f"Added comment to {args.issue}")
|
|
343
|
-
|
|
344
|
-
if args.assignee_email:
|
|
345
|
-
result = jira_get(base_url, auth, "/user/search", params={"query": args.assignee_email})
|
|
346
|
-
matches = [u for u in result if u.get("emailAddress", "").lower() == args.assignee_email.lower()]
|
|
347
|
-
if not matches:
|
|
348
|
-
print(f"ERROR: No Jira user found for email '{args.assignee_email}'", file=sys.stderr)
|
|
349
|
-
sys.exit(1)
|
|
350
|
-
account_id = matches[0]["accountId"]
|
|
351
|
-
jira_put(base_url, auth, f"/issue/{args.issue}", {"fields": {"assignee": {"id": account_id}}})
|
|
352
|
-
print(f"Assigned {args.issue} to {args.assignee_email}")
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def cmd_user(args):
|
|
356
|
-
"""Find Jira account ID by email — used to set reporter/assignee."""
|
|
357
|
-
base_url, auth, _ = get_client()
|
|
358
|
-
result = jira_get(base_url, auth, "/user/search", params={"query": args.email})
|
|
359
|
-
matches = [u for u in result if u.get("emailAddress", "").lower() == args.email.lower()]
|
|
360
|
-
if not matches:
|
|
361
|
-
print(f"ERROR: No Jira user found for email '{args.email}'", file=sys.stderr)
|
|
362
|
-
sys.exit(1)
|
|
363
|
-
user = matches[0]
|
|
364
|
-
print(json.dumps({
|
|
365
|
-
"account_id": user.get("accountId"),
|
|
366
|
-
"display_name": user.get("displayName"),
|
|
367
|
-
"email": user.get("emailAddress"),
|
|
368
|
-
}))
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
def parse_args():
|
|
372
|
-
p = argparse.ArgumentParser(description="Jira tool for Argus agents")
|
|
373
|
-
sub = p.add_subparsers(dest="command")
|
|
374
|
-
|
|
375
|
-
sub.add_parser("next", help="Get next To Do ticket by priority")
|
|
376
|
-
|
|
377
|
-
g = sub.add_parser("get", help="Get a specific ticket")
|
|
378
|
-
g.add_argument("--issue", required=True)
|
|
379
|
-
|
|
380
|
-
s = sub.add_parser("search", help="Search tickets")
|
|
381
|
-
s.add_argument("--status")
|
|
382
|
-
s.add_argument("--assignee")
|
|
383
|
-
|
|
384
|
-
c = sub.add_parser("create", help="Create a ticket")
|
|
385
|
-
c.add_argument("--summary", required=True)
|
|
386
|
-
c.add_argument("--description", default="")
|
|
387
|
-
c.add_argument("--type", default="Task", help="Bug, Task, Story, etc.")
|
|
388
|
-
c.add_argument("--reporter-account-id", dest="reporter_account_id", default=None)
|
|
389
|
-
c.add_argument("--epic", default=None, help="Parent epic key (e.g. IAI-10) to link this ticket under")
|
|
390
|
-
|
|
391
|
-
ce = sub.add_parser("create-epic", help="Create an epic")
|
|
392
|
-
ce.add_argument("--summary", required=True)
|
|
393
|
-
ce.add_argument("--description", default="")
|
|
394
|
-
ce.add_argument("--reporter-account-id", dest="reporter_account_id", default=None)
|
|
395
|
-
ce.add_argument("--start-date", dest="start_date", default=None, help="Start date (YYYY-MM-DD)")
|
|
396
|
-
ce.add_argument("--due-date", dest="due_date", default=None, help="Due date (YYYY-MM-DD)")
|
|
397
|
-
|
|
398
|
-
se = sub.add_parser("search-epics", help="Search epics in the project")
|
|
399
|
-
se.add_argument("--query", default=None, help="Search term to filter epic summaries")
|
|
400
|
-
|
|
401
|
-
u = sub.add_parser("update", help="Update a ticket")
|
|
402
|
-
u.add_argument("--issue", required=True)
|
|
403
|
-
u.add_argument("--status", help="Transition to this status")
|
|
404
|
-
u.add_argument("--description", help="Update description")
|
|
405
|
-
u.add_argument("--comment", help="Add a comment")
|
|
406
|
-
u.add_argument("--assignee-email", dest="assignee_email", help="Assign to this email address")
|
|
407
|
-
|
|
408
|
-
usr = sub.add_parser("user", help="Find Jira account ID by email")
|
|
409
|
-
usr.add_argument("--email", required=True)
|
|
410
|
-
|
|
411
|
-
return p.parse_args()
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def main():
|
|
415
|
-
args = parse_args()
|
|
416
|
-
if not args.command:
|
|
417
|
-
print("ERROR: specify a command. Use --help for usage.", file=sys.stderr)
|
|
418
|
-
sys.exit(1)
|
|
419
|
-
try:
|
|
420
|
-
{"next": cmd_next, "get": cmd_get, "search": cmd_search,
|
|
421
|
-
"create": cmd_create, "create-epic": cmd_create_epic,
|
|
422
|
-
"search-epics": cmd_search_epics,
|
|
423
|
-
"update": cmd_update, "user": cmd_user}[args.command](args)
|
|
424
|
-
except requests.HTTPError as e:
|
|
425
|
-
print(f"ERROR: Jira API error: {e}\n{e.response.text}", file=sys.stderr)
|
|
426
|
-
sys.exit(1)
|
|
427
|
-
except Exception as e:
|
|
428
|
-
print(f"ERROR: {e}", file=sys.stderr)
|
|
429
|
-
sys.exit(1)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if __name__ == "__main__":
|
|
433
|
-
main()
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Slack channel history tool for Byte agent.
|
|
4
|
-
|
|
5
|
-
Usage:
|
|
6
|
-
# Fetch last 180 days of messages
|
|
7
|
-
python3 slack_history_tool.py history --channel C05DV1DU58R --days 180
|
|
8
|
-
|
|
9
|
-
# Fetch messages since a specific date
|
|
10
|
-
python3 slack_history_tool.py history --channel C05DV1DU58R --since 2026-01-01
|
|
11
|
-
|
|
12
|
-
# Fetch messages since a Unix timestamp (for incremental reads)
|
|
13
|
-
python3 slack_history_tool.py history --channel C05DV1DU58R --oldest 1700000000
|
|
14
|
-
|
|
15
|
-
Environment variables (required):
|
|
16
|
-
SLACK_BOT_TOKEN Bot token with channels:history or groups:history scope
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
import argparse
|
|
20
|
-
import json
|
|
21
|
-
import os
|
|
22
|
-
import sys
|
|
23
|
-
from datetime import datetime, timedelta
|
|
24
|
-
import urllib.request
|
|
25
|
-
import urllib.parse
|
|
26
|
-
|
|
27
|
-
SLACK_BOT_TOKEN = os.environ.get('SLACK_BOT_TOKEN', '')
|
|
28
|
-
|
|
29
|
-
_user_cache: dict = {}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def slack_api(method: str, params: dict) -> dict:
|
|
33
|
-
url = f'https://slack.com/api/{method}'
|
|
34
|
-
data = urllib.parse.urlencode(params).encode()
|
|
35
|
-
req = urllib.request.Request(url, data=data, method='POST')
|
|
36
|
-
# Authorization is optional — if token absent, OneCLI HTTPS proxy injects it.
|
|
37
|
-
if SLACK_BOT_TOKEN:
|
|
38
|
-
req.add_header('Authorization', f'Bearer {SLACK_BOT_TOKEN}')
|
|
39
|
-
req.add_header('Content-Type', 'application/x-www-form-urlencoded')
|
|
40
|
-
with urllib.request.urlopen(req, timeout=30) as response:
|
|
41
|
-
return json.loads(response.read())
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def resolve_user(user_id: str) -> str:
|
|
45
|
-
if not user_id:
|
|
46
|
-
return 'Unknown'
|
|
47
|
-
if user_id in _user_cache:
|
|
48
|
-
return _user_cache[user_id]
|
|
49
|
-
try:
|
|
50
|
-
result = slack_api('users.info', {'user': user_id})
|
|
51
|
-
name = (
|
|
52
|
-
result.get('user', {}).get('real_name')
|
|
53
|
-
or result.get('user', {}).get('name')
|
|
54
|
-
or user_id
|
|
55
|
-
)
|
|
56
|
-
_user_cache[user_id] = name
|
|
57
|
-
return name
|
|
58
|
-
except Exception:
|
|
59
|
-
_user_cache[user_id] = user_id
|
|
60
|
-
return user_id
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def fetch_history(channel: str, oldest: str) -> list:
|
|
64
|
-
messages = []
|
|
65
|
-
cursor = None
|
|
66
|
-
|
|
67
|
-
while True:
|
|
68
|
-
params: dict = {
|
|
69
|
-
'channel': channel,
|
|
70
|
-
'limit': 200,
|
|
71
|
-
'oldest': oldest,
|
|
72
|
-
}
|
|
73
|
-
if cursor:
|
|
74
|
-
params['cursor'] = cursor
|
|
75
|
-
|
|
76
|
-
result = slack_api('conversations.history', params)
|
|
77
|
-
|
|
78
|
-
if not result.get('ok'):
|
|
79
|
-
print(f"Error: {result.get('error', 'unknown')}", file=sys.stderr)
|
|
80
|
-
break
|
|
81
|
-
|
|
82
|
-
messages.extend(result.get('messages', []))
|
|
83
|
-
|
|
84
|
-
if not result.get('has_more'):
|
|
85
|
-
break
|
|
86
|
-
|
|
87
|
-
cursor = result.get('response_metadata', {}).get('next_cursor')
|
|
88
|
-
if not cursor:
|
|
89
|
-
break
|
|
90
|
-
|
|
91
|
-
return messages
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def format_messages(messages: list) -> str:
|
|
95
|
-
lines = []
|
|
96
|
-
# API returns newest first — reverse for chronological order
|
|
97
|
-
for msg in reversed(messages):
|
|
98
|
-
# Skip system messages (joins, topic changes, etc.)
|
|
99
|
-
if msg.get('subtype'):
|
|
100
|
-
continue
|
|
101
|
-
text = msg.get('text', '').strip()
|
|
102
|
-
if not text:
|
|
103
|
-
continue
|
|
104
|
-
|
|
105
|
-
ts = float(msg.get('ts', 0))
|
|
106
|
-
dt = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
|
|
107
|
-
user_name = resolve_user(msg.get('user', ''))
|
|
108
|
-
|
|
109
|
-
lines.append(f'[{dt}] {user_name}: {text}')
|
|
110
|
-
|
|
111
|
-
return '\n'.join(lines)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def main():
|
|
115
|
-
# SLACK_BOT_TOKEN is optional — if absent, OneCLI HTTPS proxy injects Authorization.
|
|
116
|
-
parser = argparse.ArgumentParser(description='Fetch Slack channel history')
|
|
117
|
-
subparsers = parser.add_subparsers(dest='command')
|
|
118
|
-
|
|
119
|
-
history_parser = subparsers.add_parser('history', help='Fetch channel message history')
|
|
120
|
-
history_parser.add_argument('--channel', required=True, help='Slack channel ID')
|
|
121
|
-
history_parser.add_argument('--days', type=int, default=180, help='Number of days back (default: 180)')
|
|
122
|
-
history_parser.add_argument('--since', help='Fetch since this date (ISO format: 2026-01-01)')
|
|
123
|
-
history_parser.add_argument('--oldest', help='Fetch since this Unix timestamp')
|
|
124
|
-
|
|
125
|
-
args = parser.parse_args()
|
|
126
|
-
|
|
127
|
-
if args.command == 'history':
|
|
128
|
-
if args.oldest:
|
|
129
|
-
oldest = args.oldest
|
|
130
|
-
elif args.since:
|
|
131
|
-
dt = datetime.fromisoformat(args.since)
|
|
132
|
-
oldest = str(dt.timestamp())
|
|
133
|
-
else:
|
|
134
|
-
dt = datetime.now() - timedelta(days=args.days)
|
|
135
|
-
oldest = str(dt.timestamp())
|
|
136
|
-
|
|
137
|
-
messages = fetch_history(args.channel, oldest=oldest)
|
|
138
|
-
print(format_messages(messages))
|
|
139
|
-
else:
|
|
140
|
-
parser.print_help()
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if __name__ == '__main__':
|
|
144
|
-
main()
|