This tutorial is part of a series related to the Twitter API. You can find the original Birdcage Twitter tutorial here or follow my author page to keep up with the latest additions to the series. This particular tutorial builds on Birdcage and the data models and code from the preceding tweet storm tutorial.
If you're a maker like me, you often use Twitter to share details about your creations. There's often more to say than you can fit in 140 characters, and most of your followers don't even see every individual tweet. Even if they see something you've posted about, they might favorite it and forget it. It's helpful to have a service that regularly shares different aspects of your announcement. The nature of the Twitter stream makes repetition useful, within reason; overdoing it is spammy and annoying.
This tutorial builds on my earlier tweet storm article to show you how to build a service that posts a randomly selected status update about your work on a recurring basis. This automates the task of repeating and creating variation over time to increase the likelihood that your Twitter followers will engage with your content.
Keep in mind that the Twitter API has limits on repetitive content. You'll be more successful if you offer a wide variety of variations and run the service on an account that you also use manually to share other content. Twitter will likely reject the repetitive tweets of pure marketing bots—and you'll likely run into this while testing.
Feature Requirements
The basic requirements for our feature are as follows:
- Let the user write and store a "bunch" of tweets within a group.
- On a recurring basis, randomly select one tweet from the group to post to our account.
- Repeatedly post these items at user-configurable intervals with a random time shift.
- Allow the user to set a maximum number of recurrences, e.g. 100.
- Allow the user to reset groups to restart the repeating.
Building on the infrastructure for Groups that we built in the tweet storm tutorial requires only a modest amount of additional new code for recurring tweets.
The Database Model
We'll use migration to create the Group table, which is just slightly different from the one used in the tweet storm tutorial:
./app/protected/yiic migrate create create_group_table
The code below builds the schema. Note the foreign key relation to link the group to a specific Twitter account:
<?php class m141018_004954_create_group_table extends CDbMigration { protected $MySqlOptions = 'ENGINE=InnoDB CHARSET=utf8 COLLATE=utf8_unicode_ci'; public $tablePrefix; public $tableName; public function before() { $this->tablePrefix = Yii::app()->getDb()->tablePrefix; if ($this->tablePrefix <> '') $this->tableName = $this->tablePrefix.'group'; } public function safeUp() { $this->before(); $this->createTable($this->tableName, array( 'id' => 'pk', 'account_id'=>'integer default 0', 'name'=>'string default NULL', 'slug'=>'string default NULL', 'group_type'=>'tinyint default 0', 'stage'=>'integer default 0', 'created_at' => 'DATETIME NOT NULL DEFAULT 0', 'modified_at' => 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', 'next_publish_time'=>'INTEGER DEFAULT 0', 'interval'=>'TINYINT DEFAULT 0', 'interval_random'=>'TINYINT DEFAULT 0', 'max_repeats'=>'INTEGER DEFAULT 0', 'status'=>'tinyint default 0', ), $this->MySqlOptions); $this->addForeignKey('fk_group_account', $this->tableName, 'account_id', $this->tablePrefix.'account', 'id', 'CASCADE', 'CASCADE'); } public function safeDown() { $this->before(); $this->dropForeignKey('fk_group_account', $this->tableName); $this->dropTable($this->tableName); } }
The new fields include next_publish_time
, interval
, interval_random
, max_repeats
, and status
.
We'll also use the same relational table called GroupStatus
which tracks the Status tweets within each Group:
<?php class m141018_020428_create_group_status_table extends CDbMigration { protected $MySqlOptions = 'ENGINE=InnoDB CHARSET=utf8 COLLATE=utf8_unicode_ci'; public $tablePrefix; public $tableName; public function before() { $this->tablePrefix = Yii::app()->getDb()->tablePrefix; if ($this->tablePrefix <> '') $this->tableName = $this->tablePrefix.'group_status'; } public function safeUp() { $this->before(); $this->createTable($this->tableName, array( 'id' => 'pk', 'group_id' => 'INTEGER NOT NULL', 'status_id' => 'INTEGER default 0', ), $this->MySqlOptions); $this->addForeignKey('fk_group_status_group', $this->tableName, 'group_id', $this->tablePrefix.'group', 'id', 'CASCADE', 'CASCADE'); $this->addForeignKey('fk_group_status_status', $this->tableName, 'status_id', $this->tablePrefix.'status', 'id', 'CASCADE', 'CASCADE'); } public function safeDown() { $this->before(); $this->dropForeignKey('fk_group_status_group', $this->tableName); $this->dropForeignKey('fk_group_status_status', $this->tableName); $this->dropTable($this->tableName); } }
Building the Code
Use the tweet storm tutorial to see how to use Yii's Gii to create the scaffolding code for the Group Controller as well as the models for GroupStatus
.
Here's what the "Create a Group" form looks like:
Here's the view code for the form. Notice there is JQuery that shows and hides the additional settings when the user selects a recurring type of group (as opposed to a tweet storm group):
<?php $form=$this->beginWidget('bootstrap.widgets.TbActiveForm',array( 'id'=>'group-form', 'enableAjaxValidation'=>false, )); ?> <?php if(Yii::app()->user->hasFlash('no_account') ) { $this->widget('bootstrap.widgets.TbAlert', array( 'alerts'=>array( // configurations per alert type 'no_account'=>array('block'=>true, 'fade'=>true, 'closeText'=>'×'), ), )); } ?> <p class="help-block">Fields with <span class="required">*</span> are required.</p> <?php echo $form->errorSummary($model); ?> <?php echo CHtml::activeLabel($model,'account_id',array('label'=>'Create group with which account:')); echo CHtml::activeDropDownList($model,'account_id',Account::model()->getList(),array('empty'=>'Select an Account')); ?> <?php echo $form->textFieldRow($model,'name',array('class'=>'span5','maxlength'=>255)); ?> <?php echo CHtml::activeLabel($model,'group_type',array('label'=>'Group Type:')); ?> <?php echo $form->dropDownList($model,'group_type', $model->getTypeOptions()); ?> <div id ="section_schedule"> <p><strong>Schedule Post or Start Time:</strong><br /> <em>Click the field below to set date and time</em></p> <?php $this->widget( 'ext.jui.EJuiDateTimePicker', array( 'model' => $model, 'attribute' => 'next_publish_time', 'language'=> 'en', 'mode' => 'datetime', //'datetime' or 'time' ('datetime' default) 'options' => array( 'dateFormat' => 'M d, yy', 'timeFormat' => 'hh:mm tt',//'hh:mm tt' default 'alwaysSetTime'=> true, ), ) ); ?> </div> <!-- end section schedule --> <div id ="section_recur"> <p><strong>Choose Options for Your Recurring Method (optional):</strong><br /> <?php echo CHtml::activeLabel($model,'interval',array('label'=>'Recurring: choose an interval:')); echo CHtml::activeDropDownList($model,'interval',Status::model()->getIntervalList(false),array('empty'=>'Select an interval')); ?> <?php echo CHtml::activeLabel($model,'max_repeats',array('label'=>'Maximum number of repeated posts:')); echo CHtml::activeDropDownList($model,'max_repeats',Status::model()->getMaxRepeatList(),array('empty'=>'Select a maximum number')); ?> <?php echo CHtml::activeLabel($model,'interval_random',array('label'=>'Choose a randomization period for your intervals:')); echo CHtml::activeDropDownList($model,'interval_random',Status::model()->getRandomList(),array('empty'=>'Select an interval')); ?> </div> <!-- end recur --> <div class="form-actions"> <?php $this->widget('bootstrap.widgets.TbButton', array( 'buttonType'=>'submit', 'type'=>'primary', 'label'=>$model->isNewRecord ? 'Create' : 'Save', )); ?> </div> <?php $this->endWidget(); ?> <script type="text/javascript" charset="utf-8"> $(document).ready(function() { $('#section_schedule').hide(); $('#section_method').hide(); $("#Group_group_type").change(); }); $("#Group_group_type").change(function () { var option = this.value; if (option ==0) { // tweet storm $('#section_schedule').hide(); $('#section_recur').hide(); } else if (option==10) { // recurring $('#section_schedule').show(); $('#section_recur').show(); } }); </script>
Here's what the Group controller index page looks like once you've added some groups:
Here's the code that runs the index view. First, the Group controller index action:
/** * Manages all models. */ public function actionIndex() { $model=new Group('search'); $model->unsetAttributes(); // clear any default values if(isset($_GET['Group'])) $model->attributes=$_GET['Group']; $this->render('admin',array( 'model'=>$model, )); }
Then the admin view code, which uses the Yii Gridview controller:
<?php $this->breadcrumbs=array( 'Groups'=>array('index'), 'Manage', ); $this->menu=array( array('label'=>'Add a Group','url'=>array('create')), ); Yii::app()->clientScript->registerScript('search', " $('.search-button').click(function(){ $('.search-form').toggle(); return false; }); $('.search-form form').submit(function(){ $.fn.yiiGridView.update('group-grid', { data: $(this).serialize() }); return false; }); "); ?> <h1>Manage Groups of Tweets</h1> <?php $this->widget('bootstrap.widgets.TbGridView',array( 'id'=>'group-grid', 'dataProvider'=>$model->search(), 'filter'=>$model, 'columns'=>array( array( 'header'=>'Account', 'name'=>'account_id', 'value'=>array(Account::model(),'renderAccount'), ), 'name', 'slug', array( 'header'=>'Type', 'name'=>'group_type', 'value'=>array(Group::model(),'renderGroupType'), ), array( 'name'=>'status', 'header' => 'Status', 'value' => array($model,'renderStatus'), ), 'stage', array( 'htmlOptions'=>array('width'=>'150px'), 'class'=>'bootstrap.widgets.TbButtonColumn', 'header'=>'Options', 'template'=>'{manage} {update} {delete}', 'buttons'=>array ( 'manage' => array ( 'options'=>array('title'=>'Manage'), 'label'=>'<i class="icon-list icon-large" style="margin:5px;"></i>', 'url'=>'Yii::app()->createUrl("group/view", array("id"=>$data->id))', ), ), ), // end button array ), )); ?>
You can reach the Group view page by clicking on the leftmost "list" icon for any individual group. From there, you can add individual tweets:
The compose code is nearly identical to that in the Birdcage repository. Once you've added a handful of status tweets to your group, it will look something like this:
The more volume and variety of tweets you can provide, the more likely you'll be able to successfully repost automated tweets without running into Twitter's built-in limitations on bots.
Recurring groups don't actually begin tweeting until you activate them—notice the right-side menu options in the above image. Recurring groups run until the number of stages reaches the maximum number of posted tweets. The user can also reset a group to start the process over.
Here's the code for the activation and reset operations:
public function activate($group_id) { // create an action to publish the storm in the background $gp = Group::model()->findByPK($group_id); if ($gp->status == self::STATUS_PENDING or $gp->status == self::STATUS_TERMINATED) $gp->status=self::STATUS_ACTIVE; else $gp->status=self::STATUS_TERMINATED; $gp->save(); } public function reset($group_id) { // create an action to publish the storm in the background $gp = Group::model()->findByPK($group_id); if ($gp->status == self::STATUS_TERMINATED or $gp->status == self::STATUS_COMPLETE) { $gp->status=self::STATUS_ACTIVE; $gp->stage=0; // reset stage to zero $gp->next_publish_time=time()-60; // reset to a minute ago $gp->save(); } }
Publishing the Recurring Tweets
The DaemonController
index method is called by cron in the background. This calls the Group model's processRecurring
method. This runs through each account looking for groups that are overdue.
public function processRecurring() { // loop through Birdhouse app users (usually 1) $users = User::model()->findAll(); foreach ($users as $user) { $user_id = $user['id']; echo 'User: '.$user['username'];lb(); // loop through Twitter accounts (may be multiple) $accounts = Account::model()->findAllByAttributes(array('user_id'=>$user_id)); foreach ($accounts as $account) { $account_id = $account['id']; echo 'Account: '.$account['screen_name'];lb(); $this->publishRecurring($account); } // end account loop } // end user loop }
Then, we call publishRecurring
. We use named scopes to look for recurring groups whose next_publish_time
is overdue. We process by account to minimize the number of OAuth Twitter connections needed. The scopes are shown further below.
public function publishRecurring($account) { // process any active, overdue groups $groups = Group::model()->in_account($account['id'])->recur()->active()->overdue()->findAll(); if (count($groups)>0) { // make the connection to Twitter once for each account $twitter = Yii::app()->twitter->getTwitterTokened($account['oauth_token'], $account['oauth_token_secret']); // process each overdue status foreach ($groups as $group) { // look at type // select a random status $status = Status::model()->in_specific_group($group->id)->find(array('order'=>'rand('.rand(1,255).')')); echo $status->tweet_text;lb(); // tweet it $tweet_id = Status::model()->postTweet($twitter,$status); // check maximum stage if ($group['stage']>=$group['max_repeats']) { $group['status']=self::STATUS_COMPLETE; $group['next_publish_time']=0; } else { // set next_publish time - it's okay to use status model method $group['next_publish_time']=Status::model()->getNextRecurrence($group); } $group['stage']+=1; // save updated group data in db $updated_group = Group::model()->findByPk($group['id']); $updated_group->stage = $group['stage']; $updated_group->next_publish_time = $group['next_publish_time']; $updated_group->status = $group['status']; $updated_group->save(); } // end for loop of groups } // end if groups > 0 }
Here are the ActiveRecord scopes we use to find the overdue groups:
public function scopes() { return array( 'active'=>array( 'condition'=>'status='.self::STATUS_ACTIVE, ), 'recur'=>array( 'condition'=>'group_type='.self::GROUP_TYPE_RECUR, ), 'overdue'=>array( 'condition'=>'next_publish_time < UNIX_TIMESTAMP(NOW())', ), ); } // custom scopes public function in_account($account_id=0) { $this->getDbCriteria()->mergeWith( array( 'condition'=>'account_id='.$account_id, )); return $this; }
When we find a Group that needs to be updated, we use this code to randomly select a status tweet and update the next_publish_time
:
// select a random status $status = Status::model()->in_specific_group($group->id)->find(array('order'=>'rand('.rand(1,255).')')); echo $status->tweet_text;lb(); // tweet it $tweet_id = Status::model()->postTweet($twitter,$status); // check maximum stage if ($group['stage']>=$group['max_repeats']) { $group['status']=self::STATUS_COMPLETE; $group['next_publish_time']=0; } else { // set next_publish time - it's okay to use status model method $group['next_publish_time']=Status::model()->getNextRecurrence($group); } $group['stage']+=1;
We calculate recurrence by combining the delay interval and a random time shift which helps tweets appear in different time zones and slightly different periods for reaching different users:
public function getNextRecurrence($status) { // calculates the next recurring time to post $start_time=time(); if ($status['interval'] == self::STATUS_INTERVAL_HOUR) { $hours = 1; } else if ($status['interval'] == self::STATUS_INTERVAL_THREEHOUR) { $hours = 3; } else if ($status['interval'] == self::STATUS_INTERVAL_SIXHOUR) { $hours=6; } else if ($status['interval'] == self::STATUS_INTERVAL_HALFDAY) { $hours = 12; } else if ($status['interval'] == self::STATUS_INTERVAL_DAY) { $hours=24; } else if ($status['interval'] == self::STATUS_INTERVAL_TWODAY) { $hours = 48; } else if ($status['interval'] == self::STATUS_INTERVAL_THREEDAY) { $hours = 72; } else if ($status['interval'] == self::STATUS_INTERVAL_WEEK) { $hours = 168; } $start_time+=($hours*3600); $ri = $this->getRandomInterval($status['interval_random']); if (($start_time+$ri)<time()) $start_time-=$ri; // if time before now, reverse it else $start_time+=$ri; return $start_time; } public function getRandomInterval($setting) { // gets a random interval to differently space the recurring or repeating tweets $ri = 0; if ($setting == self::STATUS_RANDOM_HALFHOUR) $ri = 30; else if ($setting == self::STATUS_RANDOM_HOUR) $ri = 60; else if ($setting == self::STATUS_RANDOM_TWOHOUR) $ri = 120; else if ($setting == self::STATUS_RANDOM_THREEHOUR) $ri = 180; else if ($setting == self::STATUS_RANDOM_SIXHOUR) $ri = 360; else if ($setting == self::STATUS_RANDOM_HALFDAY) $ri = 720; else if ($setting == self::STATUS_RANDOM_DAY) $ri = 1440; // randomize the interval if ($ri>0) $ri = rand(1,$ri); $ri = $ri*60; // times # of seconds if (rand(1,100)>50) $ri = 0 - $ri; return $ri; }
The posted results over time will look something like this:
Future Enhancements
You may wish to even out the distribution of tweets by logging the frequency each is used and choosing from the least-repeated items with each iteration.
You might also wish to allow groups to have a suffix of hashtags that can be randomly selected to vary content when you tweet.
In Closing
I hope you've found this interesting and useful. Again, you can find the original Birdcage Twitter tutorial here or follow my author page to keep up with the latest additions to the Twitter API series.
Please feel free to post your own corrections, questions and comments below. I'm especially interested in your enhancements. I do try to stay engaged with the discussion thread. You can also reach me on Twitter @reifman or email me directly.
Comments