27 | p.replace_with("\n")
28 |
29 | for ht in soup.select("a.hashtag"): # convert hashtags from links to text
30 | ht.unwrap()
31 |
32 | for link in soup.select("a"): #ocnvert 1:
134 | id = args[1]
135 | acct = args[3]
136 | handle = args[0]
137 |
138 | # print("Generating post for {}".format(handle))
139 |
140 | bot, post = generate_output(handle)
141 |
142 | # post will be None if there's no posts for the bot to learn from.
143 | # in such a case, we should just exit without doing anything.
144 | if post == None: return
145 |
146 | client = Mastodon(
147 | client_id = bot['client_id'],
148 | client_secret = bot['client_secret'],
149 | access_token = bot['secret'],
150 | api_base_url = "https://{}".format(handle.split("@")[2])
151 | )
152 |
153 | db = MySQLdb.connect(
154 | host = cfg['db_host'],
155 | user=cfg['db_user'],
156 | passwd=cfg['db_pass'],
157 | db=cfg['db_name'],
158 | use_unicode=True,
159 | charset="utf8mb4"
160 | )
161 | c = db.cursor()
162 |
163 | # print(post)
164 | visibility = bot['post_privacy'] if len(args) == 1 else args[2]
165 | visibilities = ['public', 'unlisted', 'private']
166 | if visibilities.index(visibility) < visibilities.index(bot['post_privacy']):
167 | # if post_privacy is set to a more restricted level than the visibility of the post we're replying to, use the user's setting
168 | visibility = bot['post_privacy']
169 | if acct is not None:
170 | post = "{} {}".format(acct, post)
171 |
172 | # ensure post isn't longer than bot['length']
173 | # TODO: ehhhhhhhhh
174 | post = post[:bot['length']]
175 | # send toot!!
176 | try:
177 | client.status_post(post, id, visibility = visibility, spoiler_text = bot['content_warning'])
178 | except MastodonUnauthorizedError:
179 | # user has revoked the token given to the bot
180 | # this needs to be dealt with properly later on, but for now, we'll just disable the bot
181 | c.execute("UPDATE bots SET enabled = FALSE WHERE handle = %s", (handle,))
182 | except:
183 | print("Failed to submit post for {}".format(handle))
184 |
185 | if id == None:
186 | # this wasn't a reply, it was a regular post, so update the last post date
187 | c.execute("UPDATE bots SET last_post = CURRENT_TIMESTAMP() WHERE handle = %s", (handle,))
188 | db.commit()
189 | c.close()
190 |
191 | def task_done(future):
192 | try:
193 | result = future.result() # blocks until results are ready
194 | except TimeoutError as error:
195 | if not future.silent: print("Timed out on {}.".format(future.function_data))
196 |
197 | def do_in_pool(function, data, timeout=30, silent=False):
198 | with ProcessPool(max_workers=5, max_tasks=10) as pool:
199 | for i in data:
200 | future = pool.schedule(function, args=[i], timeout=timeout)
201 | future.silent = silent
202 | future.function_data = i
203 | future.add_done_callback(task_done)
204 |
205 | def get_key():
206 | db = MySQLdb.connect(
207 | host = cfg['db_host'],
208 | user=cfg['db_user'],
209 | passwd=cfg['db_pass'],
210 | db=cfg['db_name'],
211 | use_unicode=True,
212 | charset="utf8mb4"
213 | )
214 |
215 | dc = db.cursor(MySQLdb.cursors.DictCursor)
216 | dc.execute("SELECT * FROM http_auth_key")
217 | key = dc.fetchone()
218 | if key == None:
219 | # generate new key
220 | key = {}
221 | privkey = RSA.generate(4096)
222 |
223 | key['private'] = privkey.exportKey('PEM').decode('utf-8')
224 | key['public'] = privkey.publickey().exportKey('PEM').decode('utf-8')
225 |
226 | dc.execute("INSERT INTO http_auth_key (private, public) VALUES (%s, %s)", (key['private'], key['public']))
227 |
228 | dc.close()
229 | db.commit()
230 |
231 | return key
232 |
233 | def signed_get(url, timeout = 10, additional_headers = {}, request_json = True):
234 | headers = {}
235 | if request_json:
236 | headers = {
237 | "Accept": "application/json",
238 | "Content-Type": "application/json"
239 | }
240 |
241 | headers = {**headers, **additional_headers}
242 |
243 | # sign request headers
244 | key = RSA.importKey(get_key()['private'])
245 | sigstring = ''
246 | for header, value in headers.items():
247 | sigstring += '{}: {}\n'.format(header.lower(), value)
248 |
249 | sigstring.rstrip("\n")
250 |
251 | pkcs = PKCS1_v1_5.new(key)
252 | h = SHA256.new()
253 | h.update(sigstring.encode('ascii'))
254 |
255 | signed_sigstring = b64encode(pkcs.sign(h)).decode('ascii')
256 |
257 | sig = {
258 | 'keyId': "{}/actor".format(cfg['base_uri']),
259 | 'algorithm': 'rsa-sha256',
260 | 'headers': ' '.join(headers.keys()),
261 | 'signature': signed_sigstring
262 | }
263 |
264 | sig_header = ['{}="{}"'.format(k, v) for k, v in sig.items()]
265 | headers['signature'] = ','.join(sig_header)
266 |
267 | r = requests.Request('GET', url, headers)
268 | return r.headers
269 | # return requests.get(url, timeout = timeout)
270 |
--------------------------------------------------------------------------------
/app/pages/bot/accounts_add.py:
--------------------------------------------------------------------------------
1 | from flask import session, render_template, request, redirect, url_for
2 | import requests
3 | from mastodon import Mastodon
4 | import re, json
5 |
6 | def bot_accounts_add(mysql, cfg):
7 | if request.method == 'POST':
8 | # remove leading/trailing whitespace
9 | if 'account' in request.form:
10 | session['handle'] = request.form['account'].rstrip().lstrip()
11 | if session['step'] == 1:
12 | if session['handle'] == session['bot']:
13 | error = "Bots cannot learn from themselves."
14 | return render_template("bot/accounts_add.html", error = error)
15 |
16 | # look up user
17 | handle_list = session['handle'].split('@')
18 | if len(handle_list) != 3:
19 | # not formatted correctly
20 | error = "Incorrectly formatted handle."
21 | return render_template("bot/accounts_add.html", error = error)
22 |
23 | session['username'] = handle_list[1]
24 | session['instance'] = handle_list[2]
25 |
26 | if session['instance'] in json.load(open("blacklist.json")):
27 | session['error'] = "Learning from accounts on this instance is not allowed."
28 | return redirect(url_for("render_bot_accounts_add"))
29 |
30 | try:
31 | r = requests.get("https://{}/api/v1/instance".format(session['instance']), timeout=10)
32 | except requests.exceptions.ConnectionError:
33 | error = "Couldn't connect to {}.".format(session['instance'])
34 | return render_template("bot/accounts_add.html", error = error)
35 | except:
36 | error = "An unknown error occurred."
37 | return render_template("bot/accounts_add.html", error = error)
38 |
39 | if r.status_code == 200:
40 | j = r.json()
41 | if "Pleroma" in j['version']:
42 | session['instance_type'] = "Pleroma"
43 | session['step'] += 1
44 | else:
45 | if 'contact_account' in j and 'is_pro' in j['contact_account']:
46 | # gab instance
47 | session['error'] = "Gab instances are not supported."
48 | return render_template("bot/accounts_add.html", error = error)
49 | else:
50 | session['instance_type'] = "Mastodon"
51 | session['step'] += 1
52 |
53 | else:
54 | error = "Unsupported instance type. Misskey support is planned."
55 | return render_template("bot/accounts_add.html", error = error)
56 |
57 | session['client_id'], session['client_secret'] = Mastodon.create_app(
58 | "FediBooks User Authenticator",
59 | api_base_url="https://{}".format(session['instance']),
60 | scopes=["read:statuses", "read:accounts"] if session['instance_type'] == 'Mastodon' else ["read"],
61 | website=cfg['base_uri']
62 | )
63 |
64 | client = Mastodon(
65 | client_id=session['client_id'],
66 | client_secret=session['client_secret'],
67 | api_base_url="https://{}".format(session['instance'])
68 | )
69 |
70 | session['url'] = client.auth_request_url(
71 | client_id=session['client_id'],
72 | scopes=["read:statuses", "read:accounts"] if session['instance_type'] == 'Mastodon' else ["read"]
73 | )
74 |
75 | elif session['step'] == 2:
76 | # test authentication
77 | try:
78 | client = Mastodon(client_id=session['client_id'], client_secret=session['client_secret'], api_base_url=session['instance'])
79 | session['secret'] = client.log_in(
80 | code = request.form['code'],
81 | scopes=["read:statuses", "read:accounts"] if session['instance_type'] == 'Mastodon' else ["read"],
82 | )
83 | username = client.account_verify_credentials()['username']
84 | if username != session['username']:
85 | error = "Please authenticate as {}.".format(session['username'])
86 | if username.lower() == session['username'].lower():
87 | error += " Make sure you capitalised the name properly - @user and @USER are different."
88 | return render_template("bot/accounts_add.html", error = error)
89 | except:
90 | session['step'] = 1
91 | error = "Authentication failed."
92 | return render_template("bot/accounts_add.html", error = error)
93 |
94 | # 1. download host-meta to find webfinger URL
95 | r = requests.get("https://{}/.well-known/host-meta".format(session['instance']), timeout=10)
96 | if r.status_code != 200:
97 | error = "Couldn't get host-meta."
98 | return render_template("bot/accounts_add.html", error = error)
99 |
100 | # 2. use webfinger to find user's info page
101 | # TODO: use more reliable method
102 | try:
103 | uri = re.search(r'template="([^"]+)"', r.text).group(1)
104 | uri = uri.format(uri = "{}@{}".format(session['username'], session['instance']))
105 | except:
106 | error = "Couldn't find WebFinger URL."
107 | return render_template("bot/accounts_add.html", error = error)
108 |
109 | r = requests.get(uri, headers={"Accept": "application/json"}, timeout=10)
110 | try:
111 | j = r.json()
112 | except:
113 | error = "Invalid WebFinger response."
114 | return render_template("bot/accounts_add.html", error = error)
115 |
116 | found = False
117 | for link in j['links']:
118 | if link['rel'] == 'self':
119 | # this is a link formatted like "https://instan.ce/users/username", which is what we need
120 | uri = link['href']
121 | found = True
122 | break
123 | if not found:
124 | error = "Couldn't find a valid ActivityPub outbox URL."
125 | return render_template("bot/accounts_add.html", error = error)
126 |
127 | # 3. format as outbox URL and check to make sure it works
128 | outbox = "{}/outbox?page=true".format(uri)
129 | r = requests.get(outbox, headers={"Accept": "application/json,application/activity+json"}, timeout=10)
130 | if r.status_code == 200:
131 | # success!!
132 | c = mysql.connection.cursor()
133 | c.execute("INSERT IGNORE INTO `fedi_accounts` (`handle`, `outbox`) VALUES (%s, %s)", (session['handle'], outbox))
134 | c.execute("INSERT INTO `bot_learned_accounts` (`bot_id`, `fedi_id`) VALUES (%s, %s)", (session['bot'], session['handle']))
135 | c.close()
136 | mysql.connection.commit()
137 |
138 | return redirect("/bot/accounts/{}".format(session['bot']), 303)
139 | else:
140 | error = "Couldn't access ActivityPub outbox. {} may require authenticated fetches, which FediBooks doesn't support yet.".format(session['instance'])
141 | return render_template("bot/accounts_add.html", error = error)
142 | else:
143 | # new account add request
144 | session['step'] = 1
145 |
146 | return render_template("bot/accounts_add.html", error = session.pop('error', None))
147 |
--------------------------------------------------------------------------------
/app/pages/bot/create.py:
--------------------------------------------------------------------------------
1 | from flask import request, session, render_template, redirect, url_for
2 | import requests
3 | from mastodon import Mastodon
4 | import re, json
5 |
6 | def bot_create(mysql, cfg, scopes, scopes_pleroma):
7 | if request.method == 'POST':
8 | if session['step'] == 1:
9 | # strip leading https://, if provided
10 | session['instance'] = re.match(r"^(?:https?:\/\/)?(.*)", request.form['instance']).group(1)
11 |
12 | if session['instance'] in json.load(open("blacklist.json")):
13 | session['error'] = "Creating a bot on this instance is not allowed."
14 | return redirect(url_for("render_bot_create"))
15 |
16 | # check for mastodon/pleroma
17 | try:
18 | r = requests.get("https://{}/api/v1/instance".format(session['instance']), timeout=10)
19 | except requests.ConnectionError:
20 | session['error'] = "Couldn't connect to https://{}.".format(session['instance'])
21 | return render_template("bot/create.html", error = session.pop('error', None))
22 | except:
23 | session['error'] = "An unknown error occurred while trying to load https://{}".format(session['instance'])
24 | return render_template("bot/create.html", error = session.pop('error', None))
25 |
26 | if r.status_code == 200:
27 | j = r.json()
28 | if "Pleroma" in j['version']:
29 | session['instance_type'] = "Pleroma"
30 | session['step'] += 1
31 | else:
32 | if 'contact_account' in j and 'is_pro' in j['contact_account']:
33 | # gab instance
34 | session['error'] = "Gab instances are not supported."
35 | else:
36 | session['instance_type'] = "Mastodon"
37 | session['step'] += 1
38 |
39 | else:
40 | # not a masto/pleroma instance
41 | # misskey is currently unsupported
42 | # all other instance types are also unsupported
43 | # return an error message
44 | #TODO: misskey
45 | session['error'] = "Unsupported instance type. Misskey support is planned."
46 |
47 | elif session['step'] == 2:
48 | # nothing needs to be done here, this step just informs the user that their instance type is supported
49 | session['step'] += 1
50 |
51 | elif session['step'] == 3:
52 | # authenticate with the given instance and obtain credentials
53 | if session['instance_type'] in ['Mastodon', 'Pleroma']:
54 | redirect_uri = '{}/do/authenticate_bot'.format(cfg['base_uri'])
55 |
56 | session['client_id'], session['client_secret'] = Mastodon.create_app(
57 | "FediBooks",
58 | api_base_url="https://{}".format(session['instance']),
59 | scopes=scopes if session['instance_type'] == 'Mastodon' else scopes_pleroma,
60 | redirect_uris=[redirect_uri],
61 | website=cfg['base_uri']
62 | )
63 |
64 | client = Mastodon(
65 | client_id=session['client_id'],
66 | client_secret=session['client_secret'],
67 | api_base_url="https://{}".format(session['instance'])
68 | )
69 |
70 | url = client.auth_request_url(
71 | client_id=session['client_id'],
72 | redirect_uris=redirect_uri,
73 | scopes=scopes if session['instance_type'] == 'Mastodon' else scopes_pleroma
74 | )
75 | return redirect(url, code=303)
76 |
77 | elif session['instance_type'] == 'Misskey':
78 | # todo
79 | pass
80 |
81 | else:
82 | # the user clicked next on step 2 while having an unsupported instance type
83 | # take them back home
84 | del session['instance']
85 | del session['instance_type']
86 | session['step'] = 1
87 | return redirect(url_for("home"), 303)
88 |
89 | else:
90 | if 'step' in session and session['step'] == 4:
91 | try:
92 | # test authentication
93 | client = Mastodon(client_id=session['client_id'], client_secret=session['client_secret'], api_base_url=session['instance'])
94 | session['secret'] = client.log_in(
95 | code = session['code'],
96 | scopes=scopes if session['instance_type'] == 'Mastodon' else scopes_pleroma,
97 | redirect_uri='{}/do/authenticate_bot'.format(cfg['base_uri'])
98 | )
99 | username = client.account_verify_credentials()['username']
100 | handle = "@{}@{}".format(username, session['instance'])
101 | except:
102 | # authentication error occurred
103 | error = "Authentication failed."
104 | session['step'] = 3
105 | return render_template("bot/create.html", error = error)
106 |
107 | c = mysql.connection.cursor()
108 | c.execute("SELECT COUNT(*) FROM bots WHERE handle = %s", (handle,))
109 | count = c.fetchone()
110 | if count != None and count[0] == 1:
111 | session['error'] = "{} is currently in use by another FediBooks bot.".format(handle)
112 | session['step'] = 1
113 | return redirect(url_for("render_bot_create"), 303)
114 |
115 | # authentication success!!
116 | c.execute("INSERT INTO `credentials` (client_id, client_secret, secret) VALUES (%s, %s, %s)", (session['client_id'], session['client_secret'], session['secret']))
117 | credentials_id = c.lastrowid
118 | mysql.connection.commit()
119 |
120 | # get webpush url
121 | privated, publicd = client.push_subscription_generate_keys()
122 | private = privated['privkey']
123 | public = publicd['pubkey']
124 | secret = privated['auth']
125 | client.push_subscription_set("{}/push/{}".format(cfg['base_uri'], handle), publicd, mention_events = True)
126 |
127 | c.execute("INSERT INTO `bots` (handle, user_id, credentials_id, push_public_key, push_private_key, push_secret, instance_type) VALUES (%s, %s, %s, %s, %s, %s, %s)", (handle, session['user_id'], credentials_id, public, private, secret, session['instance_type']))
128 | mysql.connection.commit()
129 | c.close()
130 |
131 | # clean up unneeded variables
132 | del session['code']
133 | del session['instance']
134 | del session['instance_type']
135 | del session['client_id']
136 | del session['client_secret']
137 |
138 | else:
139 | # user is starting a new bot create request
140 | session['step'] = 1
141 |
142 |
143 | return render_template("bot/create.html", error = session.pop('error', None))
144 |
--------------------------------------------------------------------------------
/app/pages/bot/edit.py:
--------------------------------------------------------------------------------
1 | from flask import session, request, redirect, render_template
2 | import MySQLdb
3 |
4 | def bot_edit(id, mysql):
5 | if request.method == "GET":
6 | dc = mysql.connection.cursor(MySQLdb.cursors.DictCursor)
7 | dc.execute("SELECT * FROM bots WHERE handle = %s", (id,))
8 | return render_template("bot/edit.html", bot = dc.fetchone(), error = session.pop('error', None), success = session.pop('success', None))
9 | else:
10 | # update stored settings
11 | replies_enabled = 'replies' in request.form
12 | learn_from_cw = 'cw-learning' in request.form
13 |
14 | if request.form['fake-mention-style'] not in ['full', 'brief']:
15 | session['error'] = "Invalid setting for fake mention style."
16 | return redirect("/bot/edit/{}".format(id), 303)
17 |
18 | if request.form['fake-mentions'] not in ['always', 'middle', 'never']:
19 | session['error'] = "Invalid setting for fake mentions."
20 | return redirect("/bot/edit/{}".format(id), 303)
21 |
22 | if request.form['privacy'] not in ['public', 'unlisted', 'private']:
23 | session['error'] = "Invalid setting for post privacy."
24 | return redirect("/bot/edit/{}".format(id), 303)
25 |
26 | if int(request.form['length']) < 100 or int(request.form['length']) > 5000:
27 | session['error'] = "Invalid setting for maximum post length."
28 | return redirect("/bot/edit/{}".format(id), 303)
29 |
30 | if int(request.form['freq']) < 15 or int(request.form['freq']) > 240 or int(request.form['freq']) % 5:
31 | session['error'] = "Invalid setting for post frequency."
32 | return redirect("/bot/edit/{}".format(id), 303)
33 |
34 | if len(request.form['cw']) > 128:
35 | session['error'] = "Content warning cannot exceed 128 characters."
36 | return redirect("/bot/edit/{}".format(id), 303)
37 |
38 | c = mysql.connection.cursor()
39 | try:
40 | c.execute("UPDATE bots SET replies_enabled = %s, post_frequency = %s, content_warning = %s, length = %s, fake_mentions = %s, fake_mentions_full = %s, post_privacy = %s, learn_from_cw = %s WHERE handle = %s", (
41 | replies_enabled,
42 | request.form['freq'],
43 | request.form['cw'] if request.form['cw'] != "" else None,
44 | request.form['length'],
45 | request.form['fake-mentions'],
46 | request.form['fake-mention-style'] == 'full',
47 | request.form['privacy'],
48 | learn_from_cw,
49 | id
50 | ))
51 | mysql.connection.commit()
52 | c.close()
53 | except:
54 | session['error'] = "Couldn't save your settings."
55 | return redirect("/bot/edit/{}".format(id), 303)
56 |
57 | session['success'] = True
58 | return redirect("/bot/edit/{}".format(id), 303)
59 |
--------------------------------------------------------------------------------
/app/pages/home.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, session
2 | import MySQLdb
3 |
4 | def home(mysql):
5 | if 'user_id' in session:
6 | c = mysql.connection.cursor()
7 | c.execute("SELECT COUNT(*) FROM `bots` WHERE user_id = %s", (session['user_id'],))
8 | bot_count = c.fetchone()[0]
9 | active_count = None
10 | bots = {}
11 | bot_users = {}
12 | next_posts = {}
13 |
14 | if bot_count > 0:
15 | c.execute("SELECT COUNT(*) FROM `bots` WHERE user_id = %s AND enabled = TRUE", (session['user_id'],))
16 | active_count = c.fetchone()[0]
17 | dc = mysql.connection.cursor(MySQLdb.cursors.DictCursor)
18 | dc.execute("SELECT handle, enabled, last_post, post_frequency, icon FROM `bots` WHERE user_id = %s", (session['user_id'],))
19 | bots = dc.fetchall()
20 | dc.close()
21 |
22 | for bot in bots:
23 | # multiple SELECTS is slow, maybe SELECT all at once and filter with python?
24 | c.execute("SELECT COUNT(*) FROM `bot_learned_accounts` WHERE bot_id = %s", (bot['handle'],))
25 | bot_users[bot['handle']] = c.fetchone()[0]
26 | c.execute("SELECT post_frequency - TIMESTAMPDIFF(MINUTE, last_post, CURRENT_TIMESTAMP()) FROM bots WHERE TIMESTAMPDIFF(MINUTE, last_post, CURRENT_TIMESTAMP()) <= post_frequency AND enabled = TRUE AND handle = %s", (bot['handle'],))
27 | next_post = c.fetchone()
28 | if next_post is not None:
29 | next_posts[bot['handle']] = next_post
30 |
31 | c.close()
32 | return render_template("home.html", bot_count = bot_count, active_count = active_count, bots = bots, bot_users = bot_users, next_posts = next_posts)
33 | else:
34 | return render_template("front_page.html")
35 |
--------------------------------------------------------------------------------
/app/pages/settings.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, session, request, redirect, url_for
2 | import bcrypt
3 | import MySQLdb
4 | import hashlib
5 |
6 | def settings(mysql):
7 | if request.method == 'GET':
8 | dc = mysql.connection.cursor(MySQLdb.cursors.DictCursor)
9 | dc.execute("SELECT * FROM `users` WHERE id = %s", (session['user_id'],))
10 | user = dc.fetchone()
11 | dc.close()
12 | return render_template("settings.html", user = user, error = session.pop('error', None), success = session.pop('success', None))
13 |
14 | else:
15 | # update settings
16 | c = mysql.connection.cursor()
17 |
18 | c.execute("SELECT COUNT(*) FROM users WHERE email = %s AND id != %s", (request.form['email'], session['user_id']))
19 | if c.fetchone()[0] > 0:
20 | session['error'] = "Email address already in use."
21 | return redirect(url_for("render_settings"), 303)
22 |
23 | for setting in [request.form['fetch-error'], request.form['submit-error'], request.form['reply-error'], request.form['generation-error']]:
24 | if setting not in ['once', 'always', 'never']:
25 | session['error'] = 'Invalid option "{}".'.format(setting)
26 | return redirect(url_for('render_settings'), 303)
27 |
28 | if request.form['password'] != '':
29 | # user is updating their password
30 | if len(request.form['password']) < 8:
31 | session['error'] = "Password too short."
32 | return redirect(url_for("render_settings"), 303)
33 |
34 | pw_hashed = hashlib.sha256(request.form['password'].encode('utf-8')).digest().replace(b"\0", b"\1")
35 | pw = bcrypt.hashpw(pw_hashed, bcrypt.gensalt(12))
36 | c.execute("UPDATE users SET password = %s WHERE id = %s", (pw, session['user_id']))
37 |
38 | # don't require email verification again if the new email address is the same as the old one
39 | c.execute("SELECT email_verified FROM users WHERE id = %s", (session['user_id'],))
40 | if c.fetchone()[0]:
41 | c.execute("SELECT email FROM users WHERE id = %s", (session['user_id'],))
42 | previous_email = c.fetchone()[0]
43 |
44 | email_verified = (previous_email == request.form['email'])
45 | else:
46 | email_verified = False
47 |
48 | try:
49 | c.execute("UPDATE users SET email = %s, email_verified = %s, `fetch` = %s, submit = %s, generation = %s, reply = %s WHERE id = %s", (
50 | request.form['email'],
51 | email_verified,
52 | request.form['fetch-error'],
53 | request.form['submit-error'],
54 | request.form['generation-error'],
55 | request.form['reply-error'],
56 | session['user_id']
57 | ))
58 | c.close()
59 | mysql.connection.commit()
60 | except:
61 | session['error'] = "Encountered an error while updating the database."
62 | return redirect(url_for('render_settings'), 303)
63 |
64 | session['success'] = True
65 | return redirect(url_for('render_settings'), 303)
66 |
--------------------------------------------------------------------------------
/app/scrape.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import MySQLdb
4 | import requests
5 | import json, re
6 | import functions
7 |
8 | cfg = json.load(open('config.json'))
9 |
10 | def scrape_posts(account):
11 | db = MySQLdb.connect(
12 | host = cfg['db_host'],
13 | user=cfg['db_user'],
14 | passwd=cfg['db_pass'],
15 | db=cfg['db_name'],
16 | use_unicode=True,
17 | charset="utf8mb4"
18 | )
19 | handle = account[0]
20 | outbox = account[1]
21 | # print("Scraping {}".format(handle))
22 | c = db.cursor()
23 | last_post = 0
24 | c.execute("SELECT COUNT(*) FROM `posts` WHERE `fedi_id` = %s", (handle,))
25 | count = c.fetchone()
26 | if count is not None and int(count[0]) > 0:
27 | # we've downloaded this user's posts before
28 | # find out the most recently downloaded post of theirs
29 | c.execute("SELECT `post_id` FROM `posts` WHERE `fedi_id` = %s ORDER BY `id` DESC LIMIT 1", (handle,))
30 | last_post = c.fetchone()[0]
31 |
32 | done = False
33 |
34 | try:
35 | r = requests.get(outbox, timeout = 10)
36 | j = r.json()
37 | # check for pleroma
38 | pleroma = 'next' not in j
39 | if pleroma:
40 | if 'first' in j:
41 | # backwards compatibility for older (pre-v1.0.7) pleroma instances
42 | j = j['first']
43 | else:
44 | uri = "{}&min_id={}".format(outbox, last_post)
45 | r = requests.get(uri, timeout = 10)
46 | j = r.json()
47 | except:
48 | print("Couldn't load or parse outbox at URL {}".format(outbox))
49 | done = True
50 |
51 | # here we go!
52 | # warning: scraping posts from outbox.json is messy stuff
53 | while not done and 'orderedItems' in j and len(j['orderedItems']) > 0:
54 | for oi in j['orderedItems']:
55 | if oi['type'] == "Create":
56 | # this is a status/post/toot/florp/whatever
57 | # first, check to see if we already have this in the database
58 | post_id = re.search(r"([^\/]+)/?$", oi['object']['id']).group(1) # extract 123 from https://example.com/posts/123/
59 | c.execute("SELECT COUNT(*) FROM `posts` WHERE `fedi_id` = %s AND `post_id` = %s", (handle, post_id))
60 | count = c.fetchone()
61 | if count is not None and int(count[0]) > 0:
62 | # this post is already in the DB.
63 | # we'll set done to true because we've caught up to where we were last time.
64 | done = True
65 | # we'll still iterate over the rest of the posts, though, in case there are still some new ones on this page.
66 | continue
67 |
68 | content = oi['object']['content']
69 | # remove HTML tags and such from post
70 | content = functions.extract_post(content)
71 |
72 | if len(content) > 65535:
73 | # post is too long to go into the DB
74 | continue
75 |
76 | try:
77 | c.execute("INSERT INTO `posts` (`fedi_id`, `post_id`, `content`, `cw`) VALUES (%s, %s, %s, %s)", (
78 | handle,
79 | post_id,
80 | content,
81 | 1 if (oi['object']['summary'] != None and oi['object']['summary'] != "") else 0
82 | ))
83 | except:
84 | #TODO: error handling
85 | print("Failed to insert post {} for user {}".format(post_id, handle))
86 |
87 | if not done:
88 | try:
89 | if pleroma:
90 | if 'next' in j:
91 | r = requests.get(j['next'], timeout = 10)
92 | else:
93 | done = True
94 | else:
95 | if 'prev' in j:
96 | r = requests.get(j['prev'], timeout = 10)
97 | else:
98 | done = True
99 | except requests.Timeout:
100 | print("Timed out while loading next page for {}".format(handle))
101 | except:
102 | print("Encountered unknown error while getting next page for {}".format(handle))
103 |
104 | if r.status_code == 429:
105 | # we are now being ratelimited, move on to the next user
106 | print("Hit rate limit while scraping {}".format(handle))
107 | done = True
108 | else:
109 | j = r.json()
110 |
111 | db.commit()
112 |
113 | db.commit()
114 | # print("Finished scraping {}".format(handle))
115 |
116 | print("Establishing DB connection")
117 | db = MySQLdb.connect(
118 | host = cfg['db_host'],
119 | user=cfg['db_user'],
120 | passwd=cfg['db_pass'],
121 | db=cfg['db_name'],
122 | use_unicode=True,
123 | charset="utf8mb4"
124 | )
125 |
126 | cursor = db.cursor()
127 |
128 | print("Downloading posts")
129 | cursor.execute("SELECT `handle`, `outbox` FROM `fedi_accounts` ORDER BY RAND()")
130 | accounts = cursor.fetchall()
131 | cursor.close()
132 | db.close()
133 |
134 | functions.do_in_pool(scrape_posts, accounts, timeout=60)
135 |
136 | print("Done!")
137 |
--------------------------------------------------------------------------------
/app/service.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import json
3 |
4 | import MySQLdb
5 | from mastodon import Mastodon
6 | import requests
7 |
8 | import functions
9 |
10 | cfg = json.load(open('config.json'))
11 |
12 | def update_icon(bot):
13 | try:
14 | db = MySQLdb.connect(
15 | host = cfg['db_host'],
16 | user=cfg['db_user'],
17 | passwd=cfg['db_pass'],
18 | db=cfg['db_name'],
19 | use_unicode=True,
20 | charset="utf8mb4"
21 | )
22 | except:
23 | print("Failed to connect to database.")
24 | return
25 |
26 |
27 | url = "https://{}".format(bot['handle'].split("@")[2])
28 | try:
29 | r = requests.head(url, timeout=10, allow_redirects = True)
30 | if r.status_code != 200:
31 | raise
32 | except:
33 | print("{} is down - can't update icon for {}.".format(url, bot['handle']))
34 | return
35 |
36 | client = Mastodon(
37 | client_id = bot['client_id'],
38 | client_secret = bot['client_secret'],
39 | access_token = bot['secret'],
40 | api_base_url = url
41 | )
42 |
43 |
44 | c = db.cursor()
45 | try:
46 | avatar = client.account_verify_credentials()['avatar']
47 | except:
48 | c.execute("UPDATE bots SET icon_update_time = CURRENT_TIMESTAMP() WHERE handle = %s", (bot['handle'],))
49 | db.commit()
50 | c.close()
51 | return
52 | c.execute("UPDATE bots SET icon = %s, icon_update_time = CURRENT_TIMESTAMP() WHERE handle = %s", (avatar, bot['handle']))
53 | db.commit()
54 | c.close()
55 |
56 | print("Establishing DB connection")
57 | db = MySQLdb.connect(
58 | host = cfg['db_host'],
59 | user=cfg['db_user'],
60 | passwd=cfg['db_pass'],
61 | db=cfg['db_name'],
62 | use_unicode=True,
63 | charset="utf8mb4"
64 | )
65 |
66 | print("Cleaning up database")
67 | # delete any fedi accounts we no longer need
68 | cursor = db.cursor()
69 | cursor.execute("DELETE FROM fedi_accounts WHERE handle NOT IN (SELECT fedi_id FROM bot_learned_accounts)")
70 | db.commit()
71 |
72 | print("Generating posts")
73 | cursor.execute("SELECT handle FROM bots WHERE enabled = TRUE AND TIMESTAMPDIFF(MINUTE, last_post, CURRENT_TIMESTAMP()) >= post_frequency")
74 | # cursor.execute("SELECT handle FROM bots WHERE enabled = TRUE")
75 | bots = cursor.fetchall()
76 |
77 | functions.do_in_pool(functions.make_post, bots, 15)
78 |
79 | print("Updating cached icons")
80 | dc = db.cursor(MySQLdb.cursors.DictCursor)
81 | dc.execute("""
82 | SELECT handle, instance_type, client_id, client_secret, secret
83 | FROM bots
84 | INNER JOIN credentials
85 | ON bots.credentials_id = credentials.id
86 | WHERE TIMESTAMPDIFF(HOUR, icon_update_time, CURRENT_TIMESTAMP()) > 2""")
87 | bots = dc.fetchall()
88 |
89 | functions.do_in_pool(update_icon, bots)
90 |
91 | db.commit()
92 |
--------------------------------------------------------------------------------
/app/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | from mastodon import Mastodon
4 | import json
5 |
6 | cfg = json.load(open("config.json"))
7 |
8 | scopes = ["write:statuses"]
9 |
10 | print("FediBooks needs access to an account to notify users when they've been added to bots.")
11 | print("What instance would you like FediBooks' account to be on?")
12 | instance = input("https://")
13 | client_id, client_secret = Mastodon.create_app(
14 | "FediBooks",
15 | api_base_url="https://{}".format(instance),
16 | scopes=scopes,
17 | website=cfg['base_uri']
18 | )
19 |
20 | client = Mastodon(
21 | client_id=client_id,
22 | client_secret=client_secret,
23 | api_base_url="https://{}".format(instance)
24 | )
25 |
26 | url = client.auth_request_url(
27 | client_id=client_id,
28 | scopes=scopes
29 | )
30 | print("Create an account on {}, then click this link to give FediBooks access to the account: {}".format(instance, url))
31 | print("Authorise FediBooks to access the account, then paste the code below.")
32 | code = input("Code: ")
33 |
34 | print("Authenticating...")
35 |
36 | secret = client.log_in(
37 | code = code,
38 | scopes=scopes
39 | )
40 | client.status_post("FediBooks has successfully been set up to use this account.")
41 |
42 | cfg['account'] = {
43 | 'client_id': client_id,
44 | 'client_secret': client_secret,
45 | 'secret': secret,
46 | 'instance': instance
47 | }
48 |
49 | json.dump(cfg, open('config.json', 'w'))
50 |
51 | print("Done! Thanks for using FediBooks!")
52 |
--------------------------------------------------------------------------------
/app/static/bot_generic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lynnesbian/FediBooks/ebdc384454246a330ef954c670b2cafb88943c71/app/static/bot_generic.png
--------------------------------------------------------------------------------
/app/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lynnesbian/FediBooks/ebdc384454246a330ef954c670b2cafb88943c71/app/static/favicon.ico
--------------------------------------------------------------------------------
/app/static/script.js:
--------------------------------------------------------------------------------
1 | var chatlog = [];
2 |
3 | function sendMessage() {
4 | let id = window.location.href.split("/").slice(-1)[0]
5 | message = document.getElementById("chatbox-input-box").value
6 | document.getElementById("chatbox-input-box").value = ''
7 | document.getElementById("chatbox-input-box").disabled = true;
8 | chatlog.push(["user", message])
9 | renderChatlog();
10 | var xhttp = new XMLHttpRequest();
11 | xhttp.onreadystatechange = function() {
12 | if (this.readyState == 4) {
13 | if (this.status == 200) {
14 | message = this.responseText.replace("\n", "
12 | Home
13 | An ebooks bot, named after the Twitter account horse_ebooks, is a bot that learns from posts made by users and generates its own posts, similarly to how your phone keyboard predicts what you're about to type next. The results are always messy, frequently nonsensical, and occasionally hilarious. It was too ambitious a project, and I got burned out on it. Progress was slow and the code was messy. I had a ridiculous number of features planned for it, such as complex decision trees, allowing for absurdly specific rules like "only reply to users with a T in their username if it's between 4am and 5pm on a Wednesday". I had too much planned, and additionally, I'm don't particularly like developing desktop apps. The source code is still available here, although it's of little use to anyone. I've been working on ebooks bots for a long time. My first project, mstdn-ebooks, dates back to October 2018. It's a much less ambitious project than FediBooks, but maintaining and developing it has still been a formidable undertaking. It's changed a lot since the initial version, and has worked very well for a long time. Using mstdn-ebooks is nowhere near as easy as using FediBooks. There's a long guide you'll need to follow, and you'll have to install some programs like Python and possibly Git to get it working. It runs on your own computer, meaning that if you put it into sleep mode or disconnect from the internet, it stops working. Updating mstdn-ebooks to the latest version is also your responsibility, and the error messages it gives aren't exactly user friendly. To help with these issues, I decided to create a Patreon where I offered a $2.50 per month tier to host up to three bots for you. I would take care of configuration, maintenance, updating, et cetera. This worked pretty well up until recently. A recent change in Mastodon and Pleroma adds something called authenticated fetches. Reffered to as "authorised mode" by Mastodon, this new feature prevents unauthorised instances and programs from accessing the API. In other words, if instance A blocks instance B, then instance B won't be able to see instance A's public posts anymore. However, this also means mstdn-ebooks can't see your posts. This can be fixed, however, by requesting permission from instance B. So what's the problem? Without getting too technical, downloading posts from an instance using authenticated fetches requires you to be running a server. mstdn-ebooks is not a server. FediBooks, however, does run on a server, making it possible to support authenticated fetches. Furthermore, mstdn-ebooks' code base is starting to show its age (I used to be even worse at programming, believe it or not), and I thought this gave me a good motivation to create a new project. Note that although it is possible for FediBooks to use authenticated fetches, this feature is not supported yet. FediBooks is easier to use for both me and the end user. I won't have to worry about manually patching ebooks bots and handling outdated code, and you won't have to worry about installing and running mstdn-ebooks on your own computer (or paying me to do it for you). Sounds pretty good, right? 0u0 FediBooks is one of the biggest projects I've ever taken on, and it's been wonderful working on it. I hope you enjoy it! Yup! I'm making this free because I believe all software should be free, not just in cost, but in distributability, accessibility, and so on. mstdn-ebooks is also free software, meaning that even though I charged for a mstdn-ebooks hosting service in the past, you could still do it yourself for free (or ask someone other than me to do it for you). In fact, this is still true - if you don't like FediBooks, or you want to be able to modify the code, you can use mstdn-ebooks. There's a guide here. I used to provide free hosting for mstdn-ebooks bots, but stopped when it became too much for me to handle. In the end, I was running 108 ebooks bots! The worst moment was when I accidentally wiped all the configuration files for them and had to log in to all 108 accounts and re-authorise them all... FediBooks should (fingers crossed!) require much less maintenance on my part. All I have to do is make sure the server is running, which it is at all times, and make the occasional change or update to reflect new needs. The real concern is my server, not me - let's hope it can handle all these bots! FediBooks doesn't display ads. It doesn't have any subscription models, donation bonuses, or cryptocurrency mining JavaScript (as useless as that is). It will never have any of these things. What it does have is a donation link at the bottom of the main page. If you have some money to spare and you want to donate, feel free to do so. Paying for the server will be a little tricky since I've just cut off my main source of income, but I should manage. Hopefully. By necessity, yes. FediBooks will have access to all of your public posts. Anything you've set to followers only will not be seen by FediBooks. Additionally, FediBooks has no way of accessing your direct messages. However, if you delete a post, FediBooks will still have it stored in its database. This is because checking if every single post has been deleted is impractically slow, and your instance would soon tell FediBooks to stop making so many API requests. If you create two bots that learn from account X, and then delete one of them, account X's posts will still stay in the database. If you delete both bots, and no other bots are learning from account X, then account X's posts will be deleted from the database permanently. If you then add a new bot that learns from account X, FediBooks will need to download all of your posts again, which can take quite a while. {{ bot['handle'] }} Easily create your own Mastodon/Pleroma ebooks bot from your browser. No coding required.
13 | Get started
14 | FediBooks is easy to use. Everything from the profile picture to the accounts learned from is customisable with an intuitive web UI. FediBooks is licensed under the GNU AGPLv3, a libre, copyleft license. This means that it will always be not only free of charge, but also free to distribute, free from access restrictions, and free to modify. Found a bug, or have an idea for a feature? Open a GitHub issue. Want something a little more personal? Get in touch with the developer. FediBooks is a passion project I develop and maintain in my free time. If you'd like to contribute, you can do so here.
12 | Home
13 | The settings menu allows you to change your account's email or password, as well as updating your contact information. The email and password inputs allow you to change your account's email address or password respectively. If you don't edit these inputs, your login info will not be changed. The contact settings menu allows you to specify when FediBooks should send you automated emails. There are four options for you to configure. You can specify whether you'd like to be contacted always, once, or never. Always means you will be emailed every time this error occurs. Once means you will be emailed once if this occurs, and you won't be emailed again until you check FediBooks by viewing the home page. Never means you will never be emailed if this error occurs. This error happens when your bot fails to download new posts from its followed accounts. This might happen because the instance is down, or when the admin changes authentication settings for the instance. This means that your bot was unable to post something. This might happen because the instance is down, or because you have revoked FediBooks' access to your bot from the Apps menu on your instance. This happens when your bot is unable to generate a new post. This can happen when the database doesn't contain enough unique information for your bot to synthesise a new post. For example, if your bot only has one post to learn from, this will happen. This error occurs when your bot is unable to send a new reply. This generally happens for the same reason as submission errors. Hi there! You have {{ bot_count }} bot{% if bot_count != 1 %}s{% endif %}{% if bot_count != 0 %}, {{ active_count }} of which {% if active_count == 1 %}is{% else %}are{% endif %} currently active.{% else %}.{% endif %}
13 | New bot
14 | Account settings
15 | Sign out
16 | FediBooks is a passion project I develop and maintain in my free time. If you'd like to contribute, you can do so here.
16 | Log in
17 |
22 | Sign up
23 |
");
15 | } else {
16 | message = "Encountered an error while trying to get a response.";
17 | }
18 | chatlog.push(["bot", message]);
19 | renderChatlog();
20 | document.getElementById("chatbox-input-box").disabled = false;
21 |
22 | }
23 | };
24 | xhttp.open("GET", `/bot/chat/${id}/message`, true);
25 | xhttp.send();
26 | return false;
27 | }
28 |
29 | function renderChatlog() {
30 | let chatbox = document.getElementById("chatbox");
31 | let out = "";
32 | if (chatlog.length > 50) {
33 | chatlog.shift(); //only keep the 50 most recent messages to avoid slowdown
34 | }
35 | chatlog.forEach(function(item, i) {
36 | if (item[0] == "user") {
37 | out += ` `;
38 | } else {
39 | out += ` `;
40 | }
41 | })
42 | chatbox.innerHTML = out;
43 | chatbox.scrollTop = chatbox.scrollHeight;
44 | }
45 |
--------------------------------------------------------------------------------
/app/static/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: "Roboto", sans-serif;
3 | margin: 2%;
4 | background-color: #282c37;
5 | color: white;
6 | }
7 | * {
8 | box-sizing: border-box;
9 | }
10 |
11 | .container {
12 | background-color: #444a5c;
13 | padding: 10px;
14 | }
15 | .light {
16 | background-color: #4d5366;
17 | }
18 | .column {
19 | flex-grow: 1;
20 | flex-basis: 0;
21 | padding: 10px;
22 | }
23 | .large {
24 | font-size: 1.2em;
25 | }
26 | .small {
27 | font-size: 0.8em;
28 | }
29 | .tiny {
30 | font-size: 0.5em;
31 | }
32 | .centred {
33 | text-align: center;
34 | }
35 | .thin {
36 | font-weight: 300;
37 | }
38 | .subtle {
39 | color: #ccc;
40 | }
41 | .row {
42 | display: flex;
43 | }
44 | .full-width {
45 | width: 100%;
46 | }
47 | .no-margin {
48 | margin: 0;
49 | }
50 | .left-align {
51 | text-align: left;
52 | }
53 |
54 | .panel-icon {
55 | width: 100px;
56 | height: 100px;
57 | background: center/contain url("/img/bot_generic.png");
58 | background-repeat: no-repeat;
59 | }
60 | .panel-icon.large {
61 | width: 150px;
62 | height: 150px;
63 | }
64 | .panel-icon.online, .panel-icon.offline {
65 | width: 105px;
66 | }
67 | .panel-icon.online {
68 | border-left: 5px #5c5 solid;
69 | }
70 | .panel-icon.offline {
71 | border-left: 5px #c33 solid;
72 | }
73 | .panel-icon, .panel-text, .panel-actions {
74 | display: inline-block;
75 | align-self: center;
76 | }
77 | .panel-text {
78 | flex-grow: 1;
79 | margin: 0 0 10px 15px;
80 | }
81 | .panel-name {
82 | font-size: 1.8em;
83 | margin: 10px 0;
84 | }
85 | .panel-actions {
86 | margin-right: 10px;
87 | }
88 |
89 | .button {
90 | color: white;
91 | line-height: 1.2em;
92 | padding: 10px;
93 | text-decoration: none;
94 | display: inline-block;
95 | margin: 5px 0;
96 | transition: 0.2s linear;
97 | border: none;
98 | }
99 | .button:visited {
100 | color: white;
101 | }
102 | input.button, button.button {
103 | font-size: 1em;
104 | cursor: pointer;
105 | }
106 |
107 | .btn-primary {
108 | background-color: #9370db;
109 | border-color: #9370db;
110 | }
111 | .btn-primary:hover {
112 | background-color: #7859b6;
113 | border-color: #7859b6;
114 | }
115 |
116 | .btn-secondary {
117 | background-color: #999;
118 | }
119 | .btn-secondary:hover {
120 | background-color: #777;
121 | }
122 |
123 | .btn-large, button.btn-large {
124 | font-size: 1.6em;
125 | }
126 |
127 | .btn-dangerous {
128 | background-color: #e22;
129 | }
130 | .btn-dangerous:hover {
131 | background-color: #c22;
132 | }
133 |
134 | a {
135 | color: mediumpurple;
136 | text-decoration: none;
137 | }
138 | a:visited {
139 | color: mediumpurple;
140 | }
141 |
142 | p {
143 | line-height: 1.4em;
144 | }
145 |
146 | h1 {
147 | font-size: 4em;
148 | margin-top: 10px !important;
149 | }
150 | h1, h2, h3, h4, h5, h6 {
151 | font-weight: 400;
152 | margin: 0;
153 | }
154 |
155 | form {
156 | display: inline-block;
157 | }
158 | label.important {
159 | font-size: 1.4em;
160 | margin: 10px 0;
161 | font-weight: 300;
162 | display: block;
163 | text-align: center;
164 | }
165 | input, select, textarea {
166 | font-size: 1.2em;
167 | line-height: 1.4em;
168 | border: 3px grey solid;
169 | border-radius: none;
170 | padding: 3px;
171 | font-family: "Roboto", sans-serif;
172 | }
173 | input:focus, select:focus, textarea:focus {
174 | border: 3px mediumpurple solid;
175 | }
176 |
177 | input[type="checkbox"] {
178 | height: 1.4em;
179 | }
180 |
181 | label, input {
182 | flex-basis: 0;
183 | text-align: left;
184 | }
185 | label {
186 | flex-grow: 1;
187 | }
188 |
189 | form .row {
190 | margin: 10px 0;
191 | }
192 |
193 | .coming-soon {
194 | height: 200px;
195 | width: 200px;
196 | background: center/contain url("https://lynnesbian.space/img/bune.png");
197 | display: inline-block;
198 | }
199 |
200 | .error, .success {
201 | color: white;
202 | text-align: center;
203 | font-size: 1.6em;
204 | padding: 10px;
205 | }
206 | .error {
207 | background-color: #e66;
208 | }
209 | .error.err-small {
210 | font-size: 1.0em;
211 | }
212 | .success {
213 | background-color: #6e6;
214 | }
215 |
216 | #chatbox {
217 | height: 90vh;
218 | background-color: #3d4353;
219 | padding: 10px;
220 | overflow-y: scroll;
221 | }
222 | #chatbox-input, #chatbox-input input{
223 | width: 100%;
224 | }
225 | #chatbox, #chatbox-input {
226 | max-width: 600px;
227 | margin: 0 auto;
228 | }
229 | #chatbox-input {
230 | display: block;
231 | }
232 | .message {
233 | display: inline-block;
234 | padding: 5px;
235 | min-height: 30px;
236 | max-width: 60%;
237 | margin-bottom: 5px;
238 | }
239 | .message-container.user {
240 | text-align: right;
241 | }
242 | .message-container .bot-icon {
243 | height: 30px;
244 | width: 30px;
245 | display: inline-block;
246 | padding: 5px;
247 | }
248 | .message.bot {
249 | background-color: mediumpurple;
250 | color: white;
251 | vertical-align: top;
252 | }
253 | .message.user {
254 | background-color: #ddd;
255 | color: #333;
256 | }
257 |
--------------------------------------------------------------------------------
/app/templates/about.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | About FediBooks
11 | What's an ebooks bot?
18 | What happened to the old FediBooks?
21 | Why create FediBooks?
25 | You used to charge for hosting ebooks bots for people. Why make it free? Aren't you automating yourself out of a job?
35 | I'm concerned about my privacy. If FediBooks learns from my posts, doesn't that mean you have access to all my posts?
41 | Add account
12 | Stop learning from account
12 | Create bot
12 | Delete bot
12 | Configure bot
12 | Close your FediBooks account
12 | FediBooks
11 |
15 | Learn more
16 | Source code
17 | Simple
24 | Truly Free
28 | Personal
32 | Support the author
39 | About the Settings menu
11 | When my bot(s) can't get new posts
22 | When my bot(s) can't submit new posts
24 | When my bot(s) encounter an error generating new posts
26 | When my bot(s) can't send replies
28 | Home
11 | Support the author
37 | {% if signup %}Sign up{% else %}Log in{% endif %}
12 | Report a bug
12 | Account settings
12 | Welcome!
12 |
18 | Log in to your existing account.
19 |
24 | Create a new account.
25 |