One of my favourite sorts of twitter accounts like Random Bobbinsverse and Random Hellboy that tweet out single panels for a comic. They add a little bit of joy to my timeline between the tweets about our current Grimdark reality.
Recently I’ve had a need to look into creating Twitter Chatbots and creating one of these panel posting bots seemed like a good example project.
Why call the bot A Chip of Culture? Alan Moore is said to refer to floppy comic book issues “a real slab of culture” (although I can now only find references to Warren Ellis saying Moore said it). If a full comic is a “slab” then a single panel would be a “chip”.
To get the panel posting bot working you’ll need the following:
- A Twitter account
- An application on Twitter (this requires you to have a Twitter Developer account)
- A MySQL database
- An agent on Dialogflow
- Python 3.7 (that’s what I have locally, may work on earlier versions YMMV)
Basic Bot
The core of the bot built off the back of the tutorial “How to Make a Twitter Bot in Python With Tweepy“. The tutorial results in a bot that can reply, favourite and follow. Once you’ve had a look at that, come back here and we’ll expand it to have a MySQL backend to hold some data and integrating with Dialog Flow to provide some smarts.
If you want to skip it, you can start with the code tagged “basicbot” in the repo for this bot.
Adding MySQL
Before we can interact with the database, the MySQL connector package needs to be installed:
1.
pip
install
mysql-connector-python
To keep our boilerplate code for interacting with the database in the one place, we’ll create some helper functions around our calls – connect, fetchall, insert and update. You can see the code behind these in db/db_helper.py.
The database connection details should be set as environment variables for the connect function to use:
1.
export
DB_HOST=
"your_host"
2.
export
DB_NAME=
"your_database"
3.
export
DB_USER=
"someuser"
4.
export
DB_PASS=
"apassword"
The Panels Table
The first thing we want to store in the database is a list of comic panels that we will be posting. This table will contain all the information needed to post about a panel – who the creators are, what series, when it was published etc.. The only required field will be the id. Create the table using the following SQL:
01.
CREATE
TABLE
`panels` (
02.
`id`
int
(11)
NOT
NULL
AUTO_INCREMENT,
03.
`image_uri`
varchar
(1000)
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
04.
`twitter_media_id`
varchar
(20)
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
05.
`writer`
varchar
(255)
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
06.
`artist`
varchar
(255)
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
07.
`letterer`
varchar
(255)
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
08.
`colourist`
varchar
(255)
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
09.
`inks`
varchar
(255)
CHARACTER
SET
utf8mb4
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
10.
`series`
varchar
(255)
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
11.
`issue`
int
(11)
DEFAULT
NULL
,
12.
`published`
varchar
(45)
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
13.
`last_used` datetime
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
ON
UPDATE
CURRENT_TIMESTAMP
,
14.
`number_of_uses`
int
(11)
DEFAULT
'0'
,
15.
PRIMARY
KEY
(`id`),
16.
UNIQUE
KEY
`id_UNIQUE` (`id`)
17.
) ENGINE=InnoDB AUTO_INCREMENT=22
DEFAULT
CHARSET=utf8mb4
COLLATE
=utf8mb4_unicode_ci;
- id (int): Unique identifier for the row
- image_uri (varchar): Location in filesystem of the panel image
- twitter_media_id (varchar): Media id from Twitter after image is uploaded
- writer (varchar): Who was the writer on the comic
- artist (varchar): Who was the artist on the comic
- letterer (varchar): Who was the letterer on the comic
- colourist (varchar): Who was the colourist on the comic
- inks (varchar): Who inked the comic
- series (varchar): What series the panel appeared
- issue (int): What issue the panel was in
- published (varchar): When was the issue published
- last_used (datetime): When the panel was last posted
- number_of_uses (int): How many times the panel has been posted
We’ll create a class to map a row from the panels table into an object. This class will have a property which will generate the appropriate text describing the panel. As most of the columns are optional, this function will only include the relevant information.
01.
class
Panel:
02.
def
__init__(
self
, details):
03.
self
.id
=
details[
'id'
]
04.
self
.image_uri
=
details[
'image_uri'
]
05.
self
.twitter_media_id
=
details[
'twitter_media_id'
]
06.
self
.writer
=
details[
'writer'
]
07.
self
.artist
=
details[
'artist'
]
08.
self
.letterer
=
details[
'letterer'
]
09.
self
.colourist
=
details[
'colourist'
]
10.
self
.inks
=
details[
'inks'
]
11.
self
.series
=
details[
'series'
]
12.
self
.issue
=
details[
'issue'
]
13.
self
.published
=
details[
'published'
]
14.
self
.last_used
=
details[
'last_used'
]
15.
self
.number_of_uses
=
details[
'number_of_uses'
]
16.
17.
@property
18.
def
tweet_text(
self
):
19.
tweet_text
=
""
20.
21.
if
self
.letterer:
22.
tweet_text
=
f
"L: {self.letterer} "
+
tweet_text
23.
24.
if
self
.colourist:
25.
tweet_text
=
f
"C: {self.colourist} "
+
tweet_text
26.
27.
if
self
.artist:
28.
tweet_text
=
f
"A: {self.artist} "
+
tweet_text
29.
30.
if
self
.writer:
31.
tweet_text
=
f
"W: {self.writer} "
+
tweet_text
32.
33.
if
len(tweet_text) >
0
:
34.
tweet_text
=
"\n"
+
tweet_text
35.
36.
if
self
.published:
37.
tweet_text
=
f
"{self.published} "
+
tweet_text
38.
39.
if
self
.issue:
40.
tweet_text
=
f
"No. {self.issue} "
+
tweet_text
41.
42.
if
self
.series:
43.
tweet_text
=
f
"{self.series} "
+
tweet_text
44.
45.
return
tweet_text
When posting, we want to select a panel at random from the least used panels. This means that a panel will not be reused until all other panels have been shown. We’ll add a helper function called select_panel to perform the query, shuffle the results and return a Panel object.
01.
def
select_panel():
02.
logger.info(
"Selecting a panel"
)
03.
04.
panel
=
None
05.
06.
sql_select_Query
=
"SELECT * FROM panels WHERE number_of_uses = (SELECT MIN(number_of_uses) FROM panels)"
07.
08.
records
=
fetchall(sql_select_Query)
09.
10.
logger.info(f
"Number of records available: {len(records)}"
)
11.
12.
if
len(records):
13.
shuffle(records)
14.
15.
panel
=
Panel(records[
0
])
16.
17.
return
panel
When a panel is posted, we want to store the media id of the panel image (so we don’t have to keep uploading the same image multiple times) and increment the use count. To do this, we’ll add a function called update to the panel class.
01.
def
update(
self
):
02.
logger.info(
"Updating a panel to mark used"
)
03.
04.
self
.number_of_uses
=
self
.number_of_uses
+
1
05.
06.
sql_update_query
=
"""Update panels set number_of_uses = %s, twitter_media_id = %s where id = %s"""
07.
inputData
=
(
self
.number_of_uses,
self
.twitter_media_id,
self
.id)
08.
update(sql_update_query, inputData)
09.
10.
logger.info(f
"Updated panel {self.id}"
)
Now that we can fetch a random panel and update that it has been used, let’s put it to use with a new script, autopostpanel.py.
01.
#!/usr/bin/env python
02.
# tweepy-bots/bots/autopostpanel.py
03.
04.
import
tweepy
05.
import
logging
06.
import
time
07.
import
sys
08.
import
os
09.
#This is so we can find out DB files
10.
sys.path.append(os.path.dirname(os.path.abspath(__file__))
+
'/../'
)
11.
12.
from
config
import
create_api
13.
from
db.panel
import
select_panel
14.
15.
logging.basicConfig(level
=
logging.INFO)
16.
logger
=
logging.getLogger()
17.
18.
def
post_panel(api):
19.
logger.info(
"Attempt to post a panel"
)
20.
21.
panel
=
select_panel()
22.
media_ids
=
None
23.
24.
if
panel:
25.
tweet_text
=
panel.tweet_text
26.
27.
if
not
panel.twitter_media_id:
28.
media
=
api.media_upload(panel.image_uri)
29.
panel.twitter_media_id
=
media.media_id
30.
31.
media_ids
=
[panel.twitter_media_id]
32.
else
:
33.
tweet_text
=
"No panel available!"
34.
35.
logger.info(f
"Tweeting: {tweet_text}"
)
36.
37.
tweet
=
api.update_status(
38.
status
=
tweet_text,
39.
media_ids
=
media_ids
40.
)
41.
42.
panel.update()
43.
44.
return
tweet
45.
46.
def
main():
47.
try
:
48.
api
=
create_api()
49.
post_panel(api)
50.
except
Exception as e:
51.
logger.error(
"Error on post"
, exc_info
=
True
)
52.
53.
if
__name__
=
=
"__main__"
:
54.
main()
When this script is run, it fetches a random panel from the least used panels, uploads the panel (if it hasn’t been uploaded before), tweets it out and records the usage.
The updated code for this section is under the “postpanel” tag in the repo.
Recording Replies
In the current auto-reply script, it will forget what tweets have been replied to when it stops executing and will attempt to reply again on subsequent runs. This is less than ideal as it will only annoy people seeing the same thing over and over. To avoid this, we will record when a tweet has been replied to in the database.
Create the table to hold the tweet id of replied tweets with the following SQL:
1.
CREATE
TABLE
`replies` (
2.
`id`
int
(11)
NOT
NULL
AUTO_INCREMENT,
3.
`tweet_id`
varchar
(20)
COLLATE
utf8mb4_unicode_ci
DEFAULT
NULL
,
4.
PRIMARY
KEY
(`id`)
5.
) ENGINE=InnoDB
DEFAULT
CHARSET=utf8mb4
COLLATE
=utf8mb4_unicode_ci;
To interact with this table, we will create two helper functions, last_reply to get the id of the last tweet that was replied to and did_reply to record that a tweet was replied to.
01.
def
have_replied(tweet_id):
02.
logger.info(f
"Checking if tweet {tweet_id} has already been replied to"
)
03.
04.
sql_select_Query
=
"SELECT * FROM replies WHERE tweet_id = %s LIMIT 1"
05.
06.
records
=
fetchall(sql_select_Query, (tweet_id,))
07.
08.
if
records
and
len(records) >
0
:
09.
return
True
10.
return
False
11.
12.
def
did_reply(tweet_id):
13.
logger.info(
"Adding a tweet id to mark it replied"
)
14.
15.
sql_insert_Query
=
"INSERT IGNORE INTO replies (tweet_id) VALUES (%s)"
16.
insert(sql_insert_Query, (tweet_id,))
17.
18.
def
last_reply():
19.
logger.info(
"Get the id of the last tweet replied to"
)
20.
21.
sql_select_Query
=
"SELECT * FROM ellis_panel.replies ORDER BY id DESC LIMIT 1"
22.
records
=
fetchall(sql_select_Query)
23.
24.
logger.info(f
"Number of records available: {len(records)}"
)
25.
26.
if
len(records) <
1
:
27.
return
1
28.
return
int(records[
0
][
'tweet_id'
])
The autoreply.py script needs to be updated to use these functions (see lines 37 and 43 below). For did_reply we pass through the id_str parameter of the tweet object rather than the 64-bit integer id.
01.
#!/usr/bin/env python
02.
# tweepy-bots/bots/autoreply.py
03.
04.
import
tweepy
05.
import
logging
06.
from
config
import
create_api
07.
import
time
08.
import
sys
09.
import
os
10.
#This is so we can find out DB files
11.
sys.path.append(os.path.dirname(os.path.abspath(__file__))
+
'/../'
)
12.
13.
from
db.replies
import
last_reply, did_reply
14.
15.
logging.basicConfig(level
=
logging.INFO)
16.
logger
=
logging.getLogger()
17.
18.
def
check_mentions(api, keywords, since_id):
19.
logger.info(
"Retrieving mentions"
)
20.
new_since_id
=
since_id
21.
for
tweet
in
tweepy.Cursor(api.mentions_timeline,
22.
since_id
=
since_id).items():
23.
new_since_id
=
max(tweet.id, new_since_id)
24.
if
tweet.in_reply_to_status_id
is
not
None
:
25.
continue
26.
if
any(keyword
in
tweet.text.lower()
for
keyword
in
keywords):
27.
logger.info(f
"Answering to {tweet.user.name}"
)
28.
29.
if
not
tweet.user.following:
30.
tweet.user.follow()
31.
32.
api.update_status(
33.
status
=
"Please reach us via DM"
,
34.
in_reply_to_status_id
=
tweet.id,
35.
)
36.
37.
did_reply(tweet.id_str)
38.
39.
return
new_since_id
40.
41.
def
main():
42.
api
=
create_api()
43.
since_id
=
last_reply()
44.
while
True
:
45.
since_id
=
check_mentions(api, [
"help"
,
"support"
], since_id)
46.
logger.info(
"Waiting..."
)
47.
time.sleep(
60
)
48.
49.
if
__name__
=
=
"__main__"
:
50.
main()
When the script is run, it will ignore tweets that have already been replied to and avoid duplicates statuses (that would be rejected by the Twitter API anyway) and won’t annoy other users by bombarding them with the same response.
The updated code for this section is under the “recordreply” tag in the repo.
Integrating with Dialogflow
Now that we know when we’ve responded to a mention, let’s make our responses a little more intelligent by passing them through Dialogflow.
If you haven’t already, sign up to Dialogflow and login to the console.
An agent needs to be created to mange the interactions. Hit ‘Create Agent’. Call the agent ‘ChipofCulture’ (no white space is allowed in the name) and hit ‘Create’. All the other options are configurable later in the settings page.
We now have an agent but it will only respond to the default questions. To correctly respond to mentions, we need to set up intents that will interpret what has been said and respond appropriately. The intent we will set up is to figure out when the bot has been asked to send a panel.
Click ‘Create Intent’. Call the intent ‘Random Panel’. Before the intent can be triggered, some example phrases need to be added. Based on these training phrases the agent will try and match was is sent to it and return the appropriate intent. Click ‘Add Training Phrase’. Enter ‘Show me a comic’ and ‘Send me a panel’. Hit ‘Save’.
You can test the intent by typing into the input box on the top right. If you enter ‘show me a panel’ (a combination of our two phrases) you will see that the correct intent is picked up.

The last piece is to add an action to the intent. this action will be used later in our scripts. Click ‘Add Parameters and Action’. Put ‘send_panel’ in the ‘Enter action name’ input box. Hit ‘Save’. Test the intent again and you’ll see the action is filled out.
If you hit the ‘Diagnostic Info’ button after testing your intent, you will see the raw JSON that will be retuned by the API.
We’re now ready to access the agent from our scripts. To open up API access, it must be turned on via the Google Cloud console. Once you are logged into the console, select APIs & Services. Click on ‘Enable APIs and Services’ and search for Dialog Flow and activate the API.
To access the API, you’ll need to create a service account to authenticate against. Download the json file and open it up. Find the key ‘client_email’ and note the generated address. Go to the Google Cloud console and click on ‘IAM’. If the e-mail address from your json file isn’t listed, click ‘add’. Enter the e-mail address in ‘New Member’ and for ‘select a role’, search for ‘Dialogflow API Client’ and click ‘Save’.
Once all this is setup, we need to install the Dialogflow package and other supporting Google packages for Python:
1.
pip
install
dialogflow
2.
pip
install
google-auth
3.
pip
install
google-cloud-storage
We now need to add a function that will take the text from a mention, pass it to Dialogflow and figure out what to do with the response.
01.
#!/usr/bin/env python
02.
# tweepy-bots/df/df_helper.py
03.
04.
import
logging
05.
06.
logging.basicConfig(level
=
logging.INFO)
07.
logger
=
logging.getLogger()
08.
09.
def
detect_intent_texts(project_id, session_id, text, language_code):
10.
"""Returns the result of detect intent with texts as inputs.
11.
12.
Using the same `session_id` between requests allows continuation
13.
of the conversation."""
14.
import
dialogflow_v2 as dialogflow
15.
from
google.protobuf.json_format
import
MessageToDict
16.
17.
session_client
=
dialogflow.SessionsClient()
18.
19.
session
=
session_client.session_path(project_id, session_id)
20.
21.
text_input
=
dialogflow.types.TextInput(text
=
text,
22.
language_code
=
language_code)
23.
24.
query_input
=
dialogflow.types.QueryInput(text
=
text_input)
25.
26.
response
=
session_client.detect_intent(session
=
session,
27.
query_input
=
query_input)
28.
29.
return
MessageToDict(response.query_result)
The response from the API is turned from a protocol buffer object into JSON before being returned.
To use the function, we call it with our agent’s project id, a session key (we’re using a freshly generated UUID), the text of the tweet and a language encoding which we’ll take from the language set in the tweet.
You find the project id on the settings page accessed by clicking on the cog icon and will be stored as an environment variable:
1.
export
DIALOGFLOW_PROJECT_ID=
"your-project-id"
The UUID is if we want to expand out capabilities and be able to keep a session going with the agent. Using this is left as an exercise for the reader.
The autoreply.py script is updated with this function call. The result returned is checked for the send_panel action
01.
def
check_mentions(api, since_id):
02.
logger.info(
"Retrieving mentions"
)
03.
new_since_id
=
since_id
04.
for
tweet
in
tweepy.Cursor(api.mentions_timeline,
05.
since_id
=
since_id).items():
06.
new_since_id
=
max(tweet.id, new_since_id)
07.
if
tweet.in_reply_to_status_id
is
not
None
:
08.
continue
09.
10.
result
=
detect_intent_texts(dialogflow_project_key, str(uuid.uuid4()), tweet.text, tweet.lang)
11.
12.
if
result[
'action'
]
=
=
'send_panel'
:
13.
logger.info(f
"Answering to {tweet.user.name}"
)
14.
15.
if
not
tweet.user.following:
16.
tweet.user.follow()
17.
18.
post_panel(api, username
=
tweet.user.screen_name, reply_tweet_id
=
tweet.id)
19.
20.
did_reply(tweet.id_str)
21.
22.
return
new_since_id
Based then on the action set in the response, we can do the appropriate thing, which here is to send a random comic panel.
The post_panel function in autopostpanel.py has changed slightly, to accept an optional Twitter handle and tweet id to use when posting a tweet. This allows for a threaded reply rather than just a random dangling tweet.
01.
def
post_panel(api, username
=
None
, reply_tweet_id
=
None
):
02.
logger.info(
"Attempt to post a panel"
)
03.
04.
panel
=
select_panel()
05.
media_ids
=
None
06.
07.
if
panel:
08.
tweet_text
=
panel.tweet_text
09.
10.
if
not
panel.twitter_media_id:
11.
media
=
api.media_upload(panel.image_uri)
12.
panel.twitter_media_id
=
media.media_id
13.
14.
media_ids
=
[panel.twitter_media_id]
15.
else
:
16.
tweet_text
=
"No panel available!"
17.
18.
if
username:
19.
tweet_text
=
f
"@{username} "
+
tweet_text
20.
21.
logger.info(f
"Tweeting: {tweet_text}"
)
22.
23.
tweet
=
api.update_status(
24.
status
=
tweet_text,
25.
media_ids
=
media_ids,
26.
in_reply_to_status_id
=
reply_tweet_id,
27.
)
28.
29.
panel.update()
30.
31.
return
tweet
Now when there is a tweet in the bot’s mentions that asks for a panel, the bot will reply directly to that tweet.
The updated code for this section is under the “adddialogflow” tag in the repo.
Conclusion
In this tutorial, we’ve expanded on a basic Twitter bot to add some state so it remembers what has been replied and given it some smarts to reply sensibly.
More intents can be easily added to the Dialogflow agent so you can respond to other questions such as asking the bot what artists does it know about or to send a panel from a particular series.
The code is available on Github in the repo achipofculture and active on the EllisPanelBot Twitter account.