Since Kyle Roche’s excellent introduction in September two important things have happened:
(1) Google Wave released a new Robot API (March 2, 2010)
(2) Salesforce launched the Developer Preview of Chatter (March 15, 2010)
So, you ask, why not bring all of these together in one nifty tutorial? Great idea!
If you’ve gotten the Hello World app on the Force.com for Google App Engine wiki working on your own or with our walkthrough for OS X you are in a good place to start working on the Google Wave connector for Chatter. If Chatter is new to you and you see objects (i.e. CustomObject__Feed) which look strange to you, you may want to reference the Chatter recipes for examples.
Here’s what we are going to do:
(1) Set up a Google Wave Robot
(2) Have the Google Wave Robot display custom objects from Salesforce and posts on those custom objects
(3) Save replies made in Google Wave as new posts on those custom objects in Salesforce
For this example, we will be using a site which aggregates tea listings from online tea vendors and allows people to review them and comment on their reviews. While working through it, try to imagine all of the other possibilities (e.g. managing customer feedback on cases, emulation of question/answers, etc.).
On to the Google Wave Robot:
First, if you don’t have a Google Wave Sandbox account, request one. Next, download the Wave Robot Java Client libraries.
Inside you should find recent versions of the following four jar files:
wave-robot-api.jargson.jaroauth.jarwave-model.jar
Copy them to the war/WEB-INF/lib directory of the project you just built. Right click on the project, then click Refresh. Right click again, go to properties, then Java Build Path. Add the four jars to your project. You should end up with something like this:
Although Google Wave Java tutorial gives us some cool things we can do with our robot we will skip ahead to what we need for our Chatter integration.
Create a second class called TeaoismRobot with the following code:
package com.joeldietz.teaoism;
import java.io.IOException;
import javax.servlet.http.*;
import java.util.*;
import com.google.wave.api.*;
import com.google.wave.api.event.*;
public class TeaoismRobot extends AbstractRobot {
@Override
protected String getRobotName() {
return "Teaoism Robot";
}
@Override
public void onWaveletSelfAdded(WaveletSelfAddedEvent event) {
Blip blip = event.getWavelet().reply("\nYou joined. That was fractastical!");
}
}
Add this to your war/WEB-INF/appengine-web.xml file:
<precompilation-enabled>true</precompilation-enabled>
It should end up something like this:
<?xml version="1.0" encoding="utf-8"?> <appengine-web-app xmlns="http://appengine.google.com/ns/1.0"> <application>yourApplicationName</application> <version>1</version> <precompilation-enabled>true</precompilation-enabled> <!-- Configure java.util.logging --> <system-properties> <property name="java.util.logging.config.file" value="WEB-INF/logging.properties"/> </system-properties> </appengine-web-app>
Add this to your war/WEB-INF/web.xml file:
<servlet>
<servlet-name>TeaoismRobot</servlet-name>
<servlet-class>com.joeldietz.teaoism.TeaoismRobot</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>TeaoismRobot</servlet-name>
<url-pattern>/_wave/*</url-pattern>
</servlet-mapping>
Now right click on your project and go to Google -> Deploy to App Engine.
Navigate to http://yourApplicationName.appspot.com/_wave/capabilities.xml and verify that the file has been automatically created. You should see something like this:
<w:robot> <w:version>ffffffff840a022a</w:version> <w:protocolversion>0.2</w:protocolversion> − <w:capabilities> <w:capability name="WAVELET_SELF_ADDED"/> </w:capabilities> </w:robot>
Now go to the Google Wave Sandbox. Click “New Wave” then the plus sign next to your picture to add a new participant to the wave. Type in “yourApplicationName@appspot.com” for the participant, hit enter, and verify that your bot responds after being added:
Cool. Now we jump into the juicy middle of our project. Let’s add a couple import statements:
import com.sforce.soap.enterprise.*; import com.sforce.soap.enterprise.sobject.SObject; import com.sforce.soap.enterprise.sobject.Review__c; import com.sforce.soap.enterprise.sobject.Review__Feed; import com.sforce.soap.enterprise.sobject.FeedPost; import java.util.logging.Logger; import java.util.logging.Level;
Review is our Chatter-enabled custom object for this example, and in order to access the Chatter feed for Review (Review__Feed), we need to generate our Enterprise library after we have Chatter-enabled this object. For more information about the enterprise library, check out the Force. wiki working on your own or with or our last post.
Assuming all is in order, we add a few variables:
private static final long serialVersionUID = -1875649295661564335L; String SFDCusername = "your username"; String SFDCpasswordAndSecToken = "your password plus security token"; EnterpriseConnection connection = null; private static final Logger LOG = Logger.getLogger(TeaoismWaveRobot.class.getName());
Now let’s add a method for getting our Reviews from Salesforce:
// Returns a list of which the first element is the review
// and all subsequent elements are Chatter posts related to that review
public ArrayList<ArrayList <String>> getReviews() {
ArrayList<ArrayList <String>> outputStrings = new ArrayList();
try {
if ( connection == null )
{
ConnectorConfig config = new ConnectorConfig();
config.setUsername(username);
config.setPassword(password);
connection = Connector.newConnection(config);
}
QueryResult result = null;
// This needs to be a chatter enabled object
result = connection.query( "SELECT Id, Tea_Listing__r.Tea_Standard_Name__r.Name, Tea_Listing__r.direct_URL__c, Tea_Listing__r.Vendor__r.name, Review_Text__c, Rating__c from Review__c limit 10");
for (int i=0; i < result.getRecords().length;i++)
{
ArrayList <String> reviewPlusPosts = new ArrayList <String>();
Review__c review = (Review__c)result.getRecords()[i];
// position 0
reviewPlusPosts.add(review.getId().toString());
String myReview = "Tea name:" + review.getTea_Listing__r().getTea_Standard_Name__r().getName() + "\n\n";
myReview += "Sold by:" + review.getTea_Listing__r().getVendor__r().getName() + "\n\n";
myReview += "Review text:" + review.getReview_Text__c() + "\n\n";
myReview += "URL:" + review.getTea_Listing__r().getDirect_URL__c() + "\n\n";
myReview += "Rating:" + review.getRating__c() + "\n\n";
// position 1
reviewPlusPosts.add(myReview);
String queryString = "SELECT Id, FeedPost.Body, CreatedDate from Review__Feed where FeedPost.Body != null and parentid='" + review.getId() + "'";
QueryResult postQR = connection.query(queryString);
for (int j = 0; j < postQR.getRecords().length;j++)
{
Review__Feed rf = (Review__Feed)postQR.getRecords()[j];
String reviewPost = "Post: " + rf.getFeedPost().getBody() + "\n\n";
reviewPost += "Posted At: " + rf.getCreatedDate().getTime();
reviewPlusPosts.add(reviewPost);
}
outputStrings.add(reviewPlusPosts);
} // outer for
LOG.log(Level.WARNING, "OS:" + outputStrings.size());
} catch (ConnectionException ce) {
ArrayList <String> errorMsg = new ArrayList <String>();
errorMsg.add("Connection Error:" + ce.getMessage() + ":" + ce.getClass());
outputStrings.add(errorMsg);
}
return outputStrings;
}
For each review we are putting the id of the review in the first position (index 0), the body of the review in the second position (index 1), and all posts related to that review in all subsequent positions.
Although ideally we would be using a connection manager such as helpfully provided here, for the purposes of this demo we are simply checking if we have a connection already.
Here is where we output the generated code:
@Override
// The following line prevents us from sending extra data over the wire
@Capability(contexts = {Context.SELF})
public void onWaveletSelfAdded(WaveletSelfAddedEvent event) {
LOG.info("onWaveletSelfAdded");
Wavelet wavelet = event.getWavelet();
wavelet.setTitle("Fractastical!");
wavelet.getRootBlip().append("\n");
//for kicks
HashMap<String, String> imageProps = new HashMap<String, String>();
imageProps.put("width", "97");
imageProps.put("height", "78");
imageProps.put("url", "http://i909.photobucket.com/albums/ac295/fractastical/M_fractals_010-1.jpg");
wavelet.getRootBlip().append(new Element(ElementType.IMAGE, imageProps));
wavelet.getRootBlip().append("\nIt works! Now let's see some reviews!");
ArrayList<ArrayList <String>> reviews = getReviews();
for(ArrayList<String> r : reviews)
{
if (r.size() > 1)
{
Blip b = wavelet.getRootBlip().reply();
b.all().annotate("teaoism.appspot.com/REVIEWID", r.get(0));
b.all().annotate("teaoism.appspot.com/ROBOT_GENERATED", "TRUE");
b.append(r.get(1));
for(Integer i=2;i < r.size();i++)
{
Blip reply = b.reply();
reply.all().annotate("REVIEWID", r.get(0));
reply.all().annotate("ROBOT_GENERATED", "TRUE");
reply.append(r.get(i));
}
}
else
for (String os : r)
wavelet.getRootBlip().append(os);
}
wavelet.getRootBlip().append("\nThere they are!");
}
You will need to replace your previous onWaveletSelfAdded method with this one. The most basic component of the Google Wave Model is the blip. This method creates blips which contain reviews that are each replies of the root blip of the Wavelet, then creates blips that are replies of the review blips. Each blip is annotated with the review id and a note specifying that it was robot generated.
If we don’t have enough records, this indicates that something most likely went wrong with our queries or connection and we simply output whatever message we got back.
If you want, deploy this to the Google App engine and verify that it is correctly displaying all of your objects and related posts.
Next, the other direction. Here is a method that runs each time that a Blip is submitted:
@Override
@Capability(contexts = {Context.SELF, Context.PARENT})
public void onBlipSubmitted(BlipSubmittedEvent e) {
LOG.log(Level.WARNING, "WaveletBlipCreatedEvent");
Blip blip = e.getBlip();
String robotGen = "FALSE";
for(Annotation a : blip.getAnnotations())
{
//We were unable to use the get(String name) method for Annotations so we have to iterate through them all
if(a.getName() == "teaoism.appspot.com/ROBOT_GENERATED")
robotGen = a.getValue();
}
if (robotGen != "TRUE")
{
String reviewId = "";
Blip parentBlip = blip.getParentBlip();
if (parentBlip != null)
{
for(Annotation a : parentBlip.getAnnotations())
{
if(a.getName().equals("teaoism.appspot.com/REVIEWID"))
reviewId = a.getValue();
}
}
if (reviewId != "")
{
String result = insertFeedPost(reviewId, blip.getContent());
blip.append("\n\n" + result);
}
else {
blip.append("A corresponding review could not be found for your comment");
}
}
}
Here we check to see if the blip being submitted is generated by our robot. If it is not, we look to see if the blip that it is a reply of (the parent blip) has a review id. If it does, then we run our method to insert the new post.
This method is as follows:
public String insertFeedPost(String parentId, String body) {
String outputString = "";
try {
if ( connection == null ) {
ConnectorConfig config = new ConnectorConfig();
config.setUsername(username);
config.setPassword(password);
connection = Connector.newConnection(config);
}
} catch (ConnectionException ce) {
outputString += ce.getMessage() + ":" + ce.getClass();
}
try {
FeedPost fp = new FeedPost();
fp.setParentId(parentId);
fp.setBody(body);
connection.create(new SObject[] { (SObject)fp });
outputString = "Post inserted successfully.";
}
catch ( ConnectionException ce ) {
outputString += ce.getMessage();
}
return outputString;
}
That’s all there is to it!
If you want to create a custom Visualforce page to display your Chatter-enabled custom object and related posts, here is some additional code.
First, for our controller:
public class reviewCon {
public List<Review__c> reviews {get; set;}
public String rid {get; set;}
public reviewCon() {
}
public void init() {
reviews = [SELECT Id, Tea_Listing__r.Tea_Standard_Name__r.Name, Tea_Listing__r.direct_URL__c, Tea_Listing__r.Vendor__r.name, Review_Text__c, Rating__c from Review__c limit 10];
}
public List<Review__Feed> getReviewFeed()
{
return [SELECT Id, FeedPost.Body, CreatedDate from Review__Feed where FeedPost.Body != null and parentid=:rid];
}
}
And our Visualforce page:
<apex:page controller="reviewCon" action="{!init}">
<apex:form>
<apex:pageBlock id="rpb">
<apex:pageBlockTable value="{!reviews}" var="r">
<apex:column headerValue="Name">
<apex:outputText value="{!r.Tea_Listing__r.Tea_Standard_Name__r.Name}" />
</apex:column>
<apex:column headerValue="Review Text">
<apex:outputText value="{!r.Review_Text__c}" />
</apex:column>
<apex:column headerValue="Review Text">
<apex:commandLink value="Show Feed" reRender="reviewfeedblock">
<apex:param name="rid" value="{!r.id}" assignTo="{!rid}"/>
</apex:commandLink>
</apex:column>
</apex:pageBlockTable>
<apex:pageBlockSection title="Review Feed" id="reviewfeedblock" columns="1">
<apex:pageBlockTable value="{!reviewFeed}" var="f" columns="1" border="0">
<apex:column headerValue="Posts on this item">
<apex:outputText value=" {!f.FeedPost.Body}" id="feedbody"/>
</apex:column>
</apex:pageBlockTable>
</apex:PageBlockSection>
</apex:pageBlock>
</apex:form>
</apex:page>



4 comments
Comments feed for this article
April 1, 2010 at 6:23 pm
Jeff Douglas
One word for this… fractastical! Kick ass job. This is so cool.
April 1, 2010 at 11:53 pm
Quinton Wall
Very cool. You are going to put my demo to shame
April 11, 2010 at 8:37 pm
10 New Articles; 10 More Ways to Learn the Google Wave APIs – Google Wave Developers Sandbox Blog | Google Wave | Google Wave Invitations | Google Wave Invites | Google Wave Sandbox
[...] Writing a Salesforce Chatter Robot for Google Wave: Shows how to sync between Google Wave and Salesforce chatter, displaying Salesforce posts in a wave, and syncing wave replies to the Salesforce posts. Uses the Java SDK. [...]
April 12, 2010 at 8:57 am
[Google wave] 10 nouveaux articles; 10 nouvelles façon pour apprendre les API de Gwave
[...] La rédaction d’un robot pour la communication entre Wave et Salesforce : Indique comment synchroniser entre Google Wave et Salesforce. L’affichage des messages dans [...]