Skip to content

Commit 728f5a3

Browse files
committed
initial commit
0 parents  commit 728f5a3

6 files changed

+302
-0
lines changed

INSTALL.md

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Installation
2+
3+
To tweet your own Working Group's document changes, follow these steps:
4+
5+
## 1. Clone this Repo
6+
7+
This service isn't provided in a central repo because we don't want to have your credentials. Create a copy, preferably in your WG's organisation.
8+
9+
## 2. Create a Twitter Application
10+
11+
This is the most onerous (and frustrating) step, because:
12+
13+
* Twitter has a lot of bureaucracy around creating developer accounts, and
14+
* Twitter keeps on changing their authentication mechanisms, and making them more complex.
15+
16+
As of this writing, the steps involve are:
17+
18+
1. Log into Twitter using the account you wish to tweet from.
19+
2. Go to [developer.twitter.com](https://developer.twitter.com/)
20+
3. Click 'apply' (at the top right) and go through the steps to obtain a developer account. This may take a few days, and require followup e-mails with Twitter.
21+
4. Once your account is approved, go back to the developer site.
22+
5. Create a new project, named for your Working Group. In that project, create a new app.
23+
6. Copy the `API Key and Secret`.
24+
7. Change the `App permissions` to `Read and Write`.
25+
8. Click on the `Keys and tokens` tab and generate a new Access Token and Secret`; keep copies.
26+
27+
See also [these currently-outdated docs](https://python-twitter.readthedocs.io/en/latest/getting_started.html).
28+
29+
30+
## 3. Set Repository Secrets
31+
32+
The following repository secrets need to be created in the GitHub repository's `Settings` (under `Secrets`):
33+
34+
* `WORKING_GROUP` - the short identifier for the WG, e.g., `httpbis`, `tls`. Should be lowercase.
35+
* `TWITTER_CONSUMER_KEY` - the API Key
36+
* `TWITTER_CONSUMER_SECRET` - the API Secret
37+
* `TWITTER_TOKEN_KEY` - the Access Token
38+
* `TWITTER_TOKEN_SECRET` - the Access Secret
39+
40+
41+
## 4. Randomise the Cron Job
42+
43+
Go into [`.github/workflows/run.yml`](.github/workflows/run.yml) and change this line:
44+
45+
~~~
46+
- cron: "15 */4 * * *"
47+
~~~
48+
49+
... to randomise the minute that the job runs at, so that the datatracker API isn't overwhelmed.
50+
51+
52+
## 5. Configure Github Actions
53+
54+
1. If they aren't already, enable Actions under `Settings` -> `Actions`.
55+
2. On the same page, make sure that `Workflow permissions` is set to `Read and write permissions` (so that `LAST_SEEN` can be saved to the repo).

LAST_SEEN

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
2+
# Tweet Datatracker Events
3+
4+
This repo will tweet events related to a Working Group based upon [datatracker](https://datatracker.ietf.org/) updates.
5+
6+
See [INSTALL.md](INSTALL.md) to set it up for your Working Group.

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
python-twitter
2+
requests

tweet_events.py

+221
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import json
5+
import os
6+
import sys
7+
import time
8+
9+
import requests
10+
11+
try:
12+
import twitter
13+
except ImportError:
14+
twitter = None
15+
16+
17+
class DatatrackerTracker:
18+
API_BASE = "https://datatracker.ietf.org"
19+
CHUNK_SIZE = 250
20+
REQ_LIMIT = 10
21+
RETRY_MAX = 2
22+
RETRY_DELAY = 30
23+
INTERESTING_EVENTS = {
24+
"iesg_approved": "The IESG has approved {title} for publication as an RFC. {link}",
25+
"new_revision": "New revision {rev} of {title} published. {link}",
26+
"published_rfc": "{title} has been published as an RFC. {link}",
27+
"sent_last_call": "IETF Last Call for {title} has started. {link}",
28+
"started_iesg_process": "{title} has entered evalutation by the IESG. {link}",
29+
"changed_state": {
30+
"IETF WG state changed to <b>In WG Last Call</b> from WG Document": "{title} is now in Working Group Last Call. {link}"
31+
},
32+
}
33+
34+
def __init__(self, argv=None):
35+
self.args = self.parse_args(argv)
36+
self.twitter_api = None
37+
38+
def run(self):
39+
last_seen_id = self.get_last_seen()
40+
events = self.get_events(last_seen_id)
41+
new_last_seen = self.process_events(events, last_seen_id)
42+
self.note(f"Last event seen: {new_last_seen}")
43+
self.write_last_seen(new_last_seen)
44+
45+
def process_events(self, events, last_seen_id):
46+
for event in events:
47+
last_seen_id = event["id"]
48+
if not f"draft-ietf-{self.args.wg}" in event["doc"]:
49+
continue
50+
if self.args.debug:
51+
print(f"{event['type']} {event['desc']}")
52+
template = self.INTERESTING_EVENTS.get(event["type"], None)
53+
if type(template) is dict:
54+
template = template.get(event["desc"], None)
55+
if not template:
56+
continue
57+
try:
58+
message = self.format_message(event, template)
59+
except ValueError:
60+
break
61+
if self.args.dry_run or self.args.debug:
62+
print(message)
63+
else:
64+
try:
65+
self.tweet(message)
66+
except:
67+
break
68+
return last_seen_id
69+
70+
def get_events(self, last_seen_id=None):
71+
results = self.get_doc(
72+
f"/api/v1/doc/docevent/?format=json&limit={self.CHUNK_SIZE}"
73+
)
74+
events = results["objects"]
75+
events.reverse()
76+
if last_seen_id is None:
77+
return events
78+
req = 0
79+
while (
80+
last_seen_id not in [event["id"] for event in events]
81+
and req < self.REQ_LIMIT
82+
):
83+
req += 1
84+
next_link = results["meta"].get("next", None)
85+
results = self.get_doc(next_link)
86+
more_events = results["objects"]
87+
more_events.reverse()
88+
events.extend(more_events)
89+
new_events = [event for event in events if event["id"] > last_seen_id]
90+
if len(new_events) == len(events) and last_seen_id is not None:
91+
self.warn(f"Event ID {last_seen_id} not found.")
92+
return new_events
93+
94+
def format_message(self, event, template):
95+
doc = self.get_doc(event["doc"])
96+
title = doc["title"]
97+
name = doc["name"]
98+
ev_id = event["id"]
99+
rev = doc.get("rev", "")
100+
link = f"{self.API_BASE}/doc/{name}/"
101+
return template.format(**locals())
102+
103+
def init_twitter(self):
104+
self.twitter_api = twitter.Api(
105+
consumer_key=os.environ["TWITTER_CONSUMER_KEY"],
106+
consumer_secret=os.environ["TWITTER_CONSUMER_SECRET"],
107+
access_token_key=os.environ["TWITTER_TOKEN_KEY"],
108+
access_token_secret=os.environ["TWITTER_TOKEN_SECRET"],
109+
)
110+
111+
def tweet(self, message, retry_count=0):
112+
if self.twitter_api is None:
113+
self.init_twitter()
114+
try:
115+
status = self.twitter_api.PostUpdate(message)
116+
except twitter.error.TwitterError as why:
117+
details = why[0][0]
118+
# https://developer.twitter.com/en/support/twitter-api/error-troubleshooting#error-codes
119+
code = details.get("code", None)
120+
if code in [88, 130]:
121+
if retry_count < self.RETRY_MAX:
122+
self.warn(f"{details.get('message', 'Unknown issue')}. Retrying.")
123+
time.sleep(self.RETRY_DELAY)
124+
self.tweet(message, retry_count + 1)
125+
else:
126+
self.warn(f"Exceeded max retries. Giving up.")
127+
elif code == 187:
128+
self.warn(f"Duplicate tweet '{message}'")
129+
else:
130+
raise
131+
132+
def parse_args(self, argv):
133+
parser = argparse.ArgumentParser(
134+
description="Tweet about recent changes in IETF Working Groups"
135+
)
136+
parser.add_argument(
137+
"-g",
138+
"--group",
139+
dest="wg",
140+
required=True,
141+
help="Working Group's short name; e.g., 'tls', 'httpbis'",
142+
)
143+
parser.add_argument(
144+
"-d",
145+
"--dry-run",
146+
dest="dry_run",
147+
action="store_true",
148+
help="don't tweet; just show messages on STDOUT",
149+
)
150+
parser.add_argument(
151+
"--debug",
152+
dest="debug",
153+
action="store_true",
154+
help="Debug mode. Implies --dry-run.",
155+
)
156+
parser.add_argument(
157+
"-l",
158+
"--last-seen",
159+
dest="last_seen_id",
160+
type=int,
161+
default=None,
162+
help="last event ID seen",
163+
)
164+
parser.add_argument(
165+
"-f",
166+
"--file",
167+
dest="last_seen_file",
168+
help="file to read last seen ID from and write it back to after processing",
169+
)
170+
return parser.parse_args(argv)
171+
172+
def get_last_seen(self):
173+
last_seen_id = None
174+
if self.args.last_seen_id is not None:
175+
last_seen_id = self.args.last_seen_id
176+
elif self.args.last_seen_file:
177+
try:
178+
with open(self.args.last_seen_file) as fh:
179+
last_seen_id = int(fh.read())
180+
except IOError as why:
181+
self.warn(f"Cannot open {self.args.last_seen_file} for reading: {why}")
182+
sys.exit(1)
183+
except ValueError as why:
184+
self.warn(f"Last seen file does not contain an integer: {why}")
185+
sys.exit(1)
186+
return last_seen_id
187+
188+
def write_last_seen(self, last_seen_id):
189+
if self.args.last_seen_file:
190+
try:
191+
with open(self.args.last_seen_file, "w") as fh:
192+
fh.write(str(last_seen_id))
193+
except IOError as why:
194+
self.warn(f"Cannot open {self.args.last_seen_file} for writing: {why}")
195+
sys.exit(1)
196+
197+
def get_doc(self, doc_url):
198+
self.note(f"Fetching <{doc_url}>")
199+
try:
200+
req = requests.get(self.API_BASE + doc_url, timeout=15)
201+
except requests.exceptions.RequestException as why:
202+
self.warn(f"Request exception for <{doc_url}>: {why}")
203+
raise ValueError
204+
if req.status_code != 200:
205+
self.warn(f"Status code {req.status_code} from <{doc_url}>")
206+
raise ValueError
207+
try:
208+
return req.json()
209+
except json.decoder.JSONDecodeError as why:
210+
self.warn(f"JSON parse error from <{doc_url}>: {why}")
211+
raise ValueError
212+
213+
def note(self, message):
214+
sys.stderr.write(f"{message}\n")
215+
216+
def warn(self, message):
217+
sys.stderr.write(f"WARNING: {message}\n")
218+
219+
220+
if __name__ == "__main__":
221+
DatatrackerTracker().run()

update.sh

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/bin/bash
2+
3+
# This script is for CI updates
4+
5+
# Check to see if it has changed
6+
git status --short LAST_SEEN | grep -s "M" || exit 0
7+
8+
# setup
9+
git config user.email [email protected]
10+
git config user.name bot
11+
git remote set-url --push origin https://$GITHUB_ACTOR:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY
12+
git checkout -B main origin/main
13+
14+
# Push the changes
15+
git add LAST_SEEN
16+
git commit -m "update LAST_SEEN"
17+
git push origin main

0 commit comments

Comments
 (0)