1. Code
  2. JavaScript
  3. Web APIs

Building With the Twitter API: Repeating Tweets From a Group

Recurring tweets are more useful if you can vary the content. Learn how to build a feature that repeats tweets from a group of pre-written posts.
Scroll to top
10 min read
This post is part of a series called Building With the Twitter API.
Building With the Twitter API: Tweet Storms
Building With the Twitter API: Creating Friends to Follow
Final product imageFinal product imageFinal product image
What You'll Be Creating

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:

1
./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:

1
<?php
2
3
class m141018_004954_create_group_table extends CDbMigration
4
{
5
   protected $MySqlOptions = 'ENGINE=InnoDB CHARSET=utf8 COLLATE=utf8_unicode_ci';
6
   public $tablePrefix;
7
   public $tableName;
8
9
   public function before() {
10
     $this->tablePrefix = Yii::app()->getDb()->tablePrefix;
11
     if ($this->tablePrefix <> '')
12
       $this->tableName = $this->tablePrefix.'group';
13
   }
14
15
     public function safeUp()
16
 	{
17
 	  $this->before();
18
    $this->createTable($this->tableName, array(
19
             'id' => 'pk',
20
             'account_id'=>'integer default 0',
21
             'name'=>'string default NULL',
22
             'slug'=>'string default NULL',
23
             'group_type'=>'tinyint default 0',
24
             'stage'=>'integer default 0',
25
             'created_at' => 'DATETIME NOT NULL DEFAULT 0',
26
             'modified_at' => 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
27
             'next_publish_time'=>'INTEGER DEFAULT 0',
28
             'interval'=>'TINYINT DEFAULT 0',
29
             'interval_random'=>'TINYINT DEFAULT 0',
30
             'max_repeats'=>'INTEGER DEFAULT 0',             
31
             'status'=>'tinyint default 0',
32
               ), $this->MySqlOptions);
33
               $this->addForeignKey('fk_group_account', $this->tableName, 'account_id', $this->tablePrefix.'account', 'id', 'CASCADE', 'CASCADE');               
34
 	}
35
36
 	public function safeDown()
37
 	{
38
 	  	$this->before();
39
 	  	$this->dropForeignKey('fk_group_account', $this->tableName); 	  	
40
 	    $this->dropTable($this->tableName);
41
 	}
42
}

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:

1
<?php
2
3
class m141018_020428_create_group_status_table extends CDbMigration
4
{
5
   protected $MySqlOptions = 'ENGINE=InnoDB CHARSET=utf8 COLLATE=utf8_unicode_ci';
6
   public $tablePrefix;
7
   public $tableName;
8
9
   public function before() {
10
     $this->tablePrefix = Yii::app()->getDb()->tablePrefix;
11
     if ($this->tablePrefix <> '')
12
       $this->tableName = $this->tablePrefix.'group_status';
13
   }
14
15
     public function safeUp()
16
 	{
17
 	  $this->before();
18
    $this->createTable($this->tableName, array(
19
             'id' => 'pk',
20
             'group_id' => 'INTEGER NOT NULL',
21
             'status_id' => 'INTEGER default 0',
22
               ), $this->MySqlOptions);
23
               $this->addForeignKey('fk_group_status_group', $this->tableName, 'group_id', $this->tablePrefix.'group', 'id', 'CASCADE', 'CASCADE');               
24
               $this->addForeignKey('fk_group_status_status', $this->tableName, 'status_id', $this->tablePrefix.'status', 'id', 'CASCADE', 'CASCADE');               
25
 	}
26
27
 	public function safeDown()
28
 	{
29
 	  	$this->before();
30
 	  	$this->dropForeignKey('fk_group_status_group', $this->tableName);
31
 	  	$this->dropForeignKey('fk_group_status_status', $this->tableName);
32
 	    $this->dropTable($this->tableName);
33
 	}
34
}

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:

Create a new group for recurring tweetsCreate a new group for recurring tweetsCreate a new group for recurring tweets

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):

1
<?php $form=$this->beginWidget('bootstrap.widgets.TbActiveForm',array(
2
    'id'=>'group-form',
3
	'enableAjaxValidation'=>false,
4
)); ?>
5
6
<?php 
7
  if(Yii::app()->user->hasFlash('no_account')
8
    ) {
9
  $this->widget('bootstrap.widgets.TbAlert', array(
10
      'alerts'=>array( // configurations per alert type

11
  	    'no_account'=>array('block'=>true, 'fade'=>true, 'closeText'=>'×'), 
12
      ),
13
  ));
14
}
15
?>
16
17
	<p class="help-block">Fields with <span class="required">*</span> are required.</p>
18
19
	<?php echo $form->errorSummary($model); ?>
20
	
21
	<?php 
22
    echo CHtml::activeLabel($model,'account_id',array('label'=>'Create group with which account:')); 
23
    echo CHtml::activeDropDownList($model,'account_id',Account::model()->getList(),array('empty'=>'Select an Account'));
24
  ?>
25
  
26
	<?php echo $form->textFieldRow($model,'name',array('class'=>'span5','maxlength'=>255)); ?>
27
28
	<?php
29
    echo CHtml::activeLabel($model,'group_type',array('label'=>'Group Type:')); 
30
  ?>
31
  
32
  <?php echo $form->dropDownList($model,'group_type', $model->getTypeOptions()); ?>
33
34
35
     <div id ="section_schedule">
36
     <p><strong>Schedule Post or Start Time:</strong><br />
37
     <em>Click the field below to set date and time</em></p>
38
     <?php
39
     $this->widget(
40
         'ext.jui.EJuiDateTimePicker',
41
         array(
42
             'model'     => $model,
43
             'attribute' => 'next_publish_time',
44
             'language'=> 'en',
45
             'mode'    => 'datetime', //'datetime' or 'time' ('datetime' default)

46
             'options'   => array(
47
                 'dateFormat' => 'M d, yy',
48
                 'timeFormat' => 'hh:mm tt',//'hh:mm tt' default

49
                 'alwaysSetTime'=> true,
50
             ),
51
         )
52
     );
53
     ?>
54
     </div> <!-- end section schedule -->
55
56
     <div id ="section_recur">
57
58
     <p><strong>Choose Options for Your Recurring Method (optional):</strong><br />
59
60
61
     <?php 
62
         echo CHtml::activeLabel($model,'interval',array('label'=>'Recurring: choose an interval:')); 
63
         echo CHtml::activeDropDownList($model,'interval',Status::model()->getIntervalList(false),array('empty'=>'Select an interval'));      
64
     ?>
65
66
     <?php 
67
         echo CHtml::activeLabel($model,'max_repeats',array('label'=>'Maximum number of repeated posts:')); 
68
         echo CHtml::activeDropDownList($model,'max_repeats',Status::model()->getMaxRepeatList(),array('empty'=>'Select a maximum number'));      
69
     ?>
70
71
     <?php 
72
         echo CHtml::activeLabel($model,'interval_random',array('label'=>'Choose a randomization period for your intervals:')); 
73
         echo CHtml::activeDropDownList($model,'interval_random',Status::model()->getRandomList(),array('empty'=>'Select an interval'));      
74
     ?>
75
76
   </div> <!-- end recur -->
77
78
	<div class="form-actions">
79
		<?php $this->widget('bootstrap.widgets.TbButton', array(
80
			'buttonType'=>'submit',
81
			'type'=>'primary',
82
			'label'=>$model->isNewRecord ? 'Create' : 'Save',
83
		)); ?>
84
	</div>
85
86
<?php $this->endWidget(); ?>
87
<script type="text/javascript" charset="utf-8">
88
	$(document).ready(function()
89
	{
90
	  $('#section_schedule').hide();
91
	  $('#section_method').hide();
92
  	  $("#Group_group_type").change();
93
  	});
94
  	$("#Group_group_type").change(function () {
95
            var option = this.value;
96
            if (option ==0) {
97
              // tweet storm

98
              $('#section_schedule').hide();
99
          	  $('#section_recur').hide();    
100
            } else if (option==10) {
101
              // recurring

102
              $('#section_schedule').show();
103
          	  $('#section_recur').show();
104
            }
105
        });  
106
</script>

Here's what the Group controller index page looks like once you've added some groups:

Manage groups pageManage groups pageManage groups page

Here's the code that runs the index view. First, the Group controller index action:

1
    /**

2
	 * Manages all models.

3
	 */
4
	public function actionIndex()
5
	{
6
    
7
		$model=new Group('search');
8
		$model->unsetAttributes();  // clear any default values

9
		if(isset($_GET['Group']))
10
			$model->attributes=$_GET['Group'];
11
12
		$this->render('admin',array(
13
			'model'=>$model,
14
		));
15
	}

Then the admin view code, which uses the Yii Gridview controller:

1
<?php
2
$this->breadcrumbs=array(
3
    'Groups'=>array('index'),
4
	'Manage',
5
);
6
7
$this->menu=array(
8
	array('label'=>'Add a Group','url'=>array('create')),
9
);
10
11
Yii::app()->clientScript->registerScript('search', "

12
$('.search-button').click(function(){

13
	$('.search-form').toggle();

14
	return false;

15
});

16
$('.search-form form').submit(function(){

17
	$.fn.yiiGridView.update('group-grid', {

18
		data: $(this).serialize()

19
	});

20
	return false;

21
});

22
");
23
?>
24
25
<h1>Manage Groups of Tweets</h1>
26
27
<?php $this->widget('bootstrap.widgets.TbGridView',array(
28
	'id'=>'group-grid',
29
	'dataProvider'=>$model->search(),
30
	'filter'=>$model,
31
	'columns'=>array(
32
		array(            
33
		          'header'=>'Account',
34
                'name'=>'account_id',
35
                'value'=>array(Account::model(),'renderAccount'), 
36
            ),
37
		'name',
38
		'slug',
39
		array(            
40
		          'header'=>'Type',
41
                'name'=>'group_type',
42
                'value'=>array(Group::model(),'renderGroupType'), 
43
            ),
44
            array(
45
              'name'=>'status',
46
                  'header' => 'Status',
47
                   'value' => array($model,'renderStatus'), 
48
              ),
49
		'stage',
50
    array(
51
		  'htmlOptions'=>array('width'=>'150px'),  		
52
			'class'=>'bootstrap.widgets.TbButtonColumn',
53
			'header'=>'Options',
54
      'template'=>'{manage}  {update}  {delete}',
55
          'buttons'=>array
56
          (
57
              'manage' => array
58
              (
59
              'options'=>array('title'=>'Manage'),
60
                'label'=>'<i class="icon-list icon-large" style="margin:5px;"></i>',
61
                'url'=>'Yii::app()->createUrl("group/view", array("id"=>$data->id))',
62
              ),
63
          ),			
64
		), // end button array

65
	),
66
)); ?>

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:

Composing a future tweet for group recurringComposing a future tweet for group recurringComposing a future tweet for group recurring

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:

A Group of tweets for recurringA Group of tweets for recurringA Group of tweets for recurring

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:

1
    public function activate($group_id) {
2
	  // create an action to publish the storm in the background

3
	  $gp = Group::model()->findByPK($group_id);
4
	  if ($gp->status == self::STATUS_PENDING or $gp->status == self::STATUS_TERMINATED)
5
	    $gp->status=self::STATUS_ACTIVE;
6
	  else 
7
	    $gp->status=self::STATUS_TERMINATED;
8
	  $gp->save();
9
	}
10
11
	public function reset($group_id) {
12
	  // create an action to publish the storm in the background

13
	  $gp = Group::model()->findByPK($group_id);
14
	  if ($gp->status == self::STATUS_TERMINATED or $gp->status == self::STATUS_COMPLETE) {
15
	    $gp->status=self::STATUS_ACTIVE;
16
	    $gp->stage=0; // reset stage to zero

17
	    $gp->next_publish_time=time()-60; // reset to a minute ago

18
	    $gp->save();
19
	  }
20
	}

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.

1
public function processRecurring() {
2
     // loop through Birdhouse app users (usually 1)

3
     $users = User::model()->findAll();
4
     foreach ($users as $user) {
5
       $user_id = $user['id'];
6
       echo 'User: '.$user['username'];lb();
7
       // loop through Twitter accounts (may be multiple)

8
       $accounts = Account::model()->findAllByAttributes(array('user_id'=>$user_id));
9
       foreach ($accounts as $account) {
10
         $account_id = $account['id'];  
11
         echo 'Account: '.$account['screen_name'];lb();
12
         $this->publishRecurring($account);        
13
       } // end account loop

14
     } // end user loop

15
   }

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.

1
   public function publishRecurring($account) {     
2
     // process any active, overdue groups

3
        $groups = Group::model()->in_account($account['id'])->recur()->active()->overdue()->findAll();
4
 	   if (count($groups)>0) {
5
 	     // make the connection to Twitter once for each account

6
    	  $twitter = Yii::app()->twitter->getTwitterTokened($account['oauth_token'], $account['oauth_token_secret']);
7
    	  // process each overdue status

8
        foreach ($groups as $group) {
9
          // look at type

10
            // select a random status 

11
            $status = Status::model()->in_specific_group($group->id)->find(array('order'=>'rand('.rand(1,255).')'));
12
            echo $status->tweet_text;lb();
13
            // tweet it

14
            $tweet_id = Status::model()->postTweet($twitter,$status);
15
            // check maximum stage

16
            if ($group['stage']>=$group['max_repeats']) {
17
              $group['status']=self::STATUS_COMPLETE;
18
              $group['next_publish_time']=0;
19
            } else {
20
              // set next_publish time - it's okay to use status model method

21
                $group['next_publish_time']=Status::model()->getNextRecurrence($group);                
22
              }
23
            $group['stage']+=1;
24
            // save updated group data in db

25
            $updated_group = Group::model()->findByPk($group['id']);
26
            $updated_group->stage = $group['stage'];
27
            $updated_group->next_publish_time = $group['next_publish_time'];
28
            $updated_group->status = $group['status'];
29
            $updated_group->save();    
30
        }  // end for loop of groups

31
 	   } 	 // end if groups > 0

32
 	}  

Here are the ActiveRecord scopes we use to find the overdue groups:

1
public function scopes()
2
    {
3
        return array(   
4
          'active'=>array(
5
              'condition'=>'status='.self::STATUS_ACTIVE, 
6
          ),
7
          'recur'=>array(
8
              'condition'=>'group_type='.self::GROUP_TYPE_RECUR, 
9
          ),
10
            'overdue'=>array(
11
              'condition'=>'next_publish_time < UNIX_TIMESTAMP(NOW())',               
12
            ),
13
        );
14
    }    	
15
    
16
    // custom scopes

17
    public function in_account($account_id=0)
18
    {
19
      $this->getDbCriteria()->mergeWith( array(
20
        'condition'=>'account_id='.$account_id,
21
      ));
22
        return $this;
23
    }

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:

1
// select a random status 

2
            $status = Status::model()->in_specific_group($group->id)->find(array('order'=>'rand('.rand(1,255).')'));
3
            echo $status->tweet_text;lb();
4
            // tweet it

5
            $tweet_id = Status::model()->postTweet($twitter,$status);
6
            // check maximum stage

7
            if ($group['stage']>=$group['max_repeats']) {
8
              $group['status']=self::STATUS_COMPLETE;
9
              $group['next_publish_time']=0;
10
            } else {
11
              // set next_publish time - it's okay to use status model method

12
                $group['next_publish_time']=Status::model()->getNextRecurrence($group);                
13
              }
14
            $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:

1
    public function getNextRecurrence($status) {
2
      // calculates the next recurring time to post

3
      $start_time=time();
4
      if ($status['interval'] == self::STATUS_INTERVAL_HOUR) {
5
        $hours = 1;
6
      } else if ($status['interval'] == self::STATUS_INTERVAL_THREEHOUR) {
7
        $hours = 3;
8
      }  else if ($status['interval'] == self::STATUS_INTERVAL_SIXHOUR) {
9
          $hours=6;
10
      }   else if ($status['interval'] == self::STATUS_INTERVAL_HALFDAY) {
11
            $hours = 12;
12
        }  else if ($status['interval'] == self::STATUS_INTERVAL_DAY) {
13
              $hours=24;
14
      }  else if ($status['interval'] == self::STATUS_INTERVAL_TWODAY) {
15
            $hours = 48;
16
      }  else if ($status['interval'] == self::STATUS_INTERVAL_THREEDAY) {
17
            $hours = 72;
18
      }  else if ($status['interval'] == self::STATUS_INTERVAL_WEEK) {
19
              $hours = 168;
20
      }
21
      $start_time+=($hours*3600);
22
      $ri = $this->getRandomInterval($status['interval_random']);
23
      if (($start_time+$ri)<time()) 
24
        $start_time-=$ri; // if time before now, reverse it

25
      else
26
        $start_time+=$ri;
27
      return $start_time;
28
    }
29
    
30
    public function getRandomInterval($setting) {
31
      // gets a random interval to differently space the recurring or repeating tweets

32
      $ri = 0;
33
      if ($setting == self::STATUS_RANDOM_HALFHOUR)
34
        $ri = 30;
35
      else if ($setting == self::STATUS_RANDOM_HOUR)
36
        $ri = 60;
37
      else if ($setting == self::STATUS_RANDOM_TWOHOUR)
38
        $ri = 120;
39
      else if ($setting == self::STATUS_RANDOM_THREEHOUR)
40
        $ri = 180;
41
      else if ($setting == self::STATUS_RANDOM_SIXHOUR)
42
        $ri = 360;
43
      else if ($setting == self::STATUS_RANDOM_HALFDAY)
44
        $ri = 720;
45
      else if ($setting == self::STATUS_RANDOM_DAY)
46
        $ri = 1440;
47
      // randomize the interval

48
      if ($ri>0) $ri = rand(1,$ri);        
49
      $ri = $ri*60; // times # of seconds

50
      if (rand(1,100)>50)
51
        $ri = 0 - $ri;
52
      return $ri;
53
    }
54
 

The posted results over time will look something like this:

The results of recurring group tweetsThe results of recurring group tweetsThe results of recurring group tweets

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.