Find my posts on IT strategy, enterprise architecture, and digital transformation at ArchitectElevator.com.
By now everyone must have heard about Google Wave, the communication and collaboration platform announced at Google I/O. Google just announced that they are ramping up towards 20,000 developers on the current sandbox and will extend to 100,000 users by end of September. This will give many more people to opportunity to experiment with Wave.
One of the key features of Wave is its extensibility. For example, you can create robots, which can participate in waves just like humans do (that is once they are invited in). As I hinted last time, I intended to build a "make the Sun lawyers happy" robot, which would replace any mention of "Java" with the preferred version of "Java(TM)-based technology". Alas, I was not able to get it working in time for JavaOne. So I took advantage of the Tokyo Wave Hackathon organized by the Tokyo Google Technology User group to receive some debugging aid and get my robot working.
Wave has two primary extension mechanisms, gadgets and robots. Gadgets are embedded inside a wave and run inside the browser client. Robots are automated wave participants, which can edit text, add new blips, etc. Time for a super-quick Wave vocabulary lesson: interaction between participants happens through waves (think "discussion thread++"), which contain wavelets made up of blips. In a traditional e-mail thread, a blip would correspond to a single e-mail post. Unlike gadgets, robots are hosted externally and can be invited to a wave just like a human user. Currently, all robots are hosted via Google App Engine at appspot.com , but I hear that restriction is going to be lifted in the future. Since I am a back-end kind of guy (and my JavaScript skills are very crude), I decided to write a robot.
Writing robots is relatively simple thanks to the Wave Robot API, which was just open sourced. App Engine (for Java) works with servlets, so it's natural that the API provides a servlet base class, AbstractRobotServlet. Your own servlet inherits from this base class and gains access to methods that check for different event types and read or modify the wave's blips. Of course, the App Engine servlet as access to all the other App Engine API's, such as persistence through JPA or JDO.
A robot cannot simply call Wave or read a wave. It has to be explicitly invited by
a user first. The robot name is currently derived from the App Engine name, meaning
your servlet at xyz.appspot.com
will be known to the wave sandbox as xyz@appspot.com
. Once invited into a wave conversation, the robot reacts to events issued by wave,
such as the addition of a new participant or the submission of a new blip. Each robot
exposes a capabilities.xml
file, which describes the events that the robot would like to listen to.
My robot's task is to replace text, particularly occurrences of "Java" with "Java(TM)-based technology. Playing an existing wave back through the cool Playback feature shows what the robot did to the sentence containing Java (it also played with the number of "o"s in "Google"):
Since I like building software in small iterations, I started with a simple, hard-coded
replacement. Whenever a blip with the word "Java" is submitted to a wave, which the
robot listens to, the robot will replace this particular text. Any robot needs a capabilities.xml
file, so I start there. The Wave Robot Overview shows an example file, but forgets to list all allowable capabilities. I assume they
match the names of the constants in EventType.java. I did not see a statement as to whether casing matters, so I'll stick with the lowercase
from the sample. Details, details – what do I expect from living on the bleeding edge?
(note: it seems that in fact casing does not matter) Our robot only needs to subscribe
to a single event, blip_submitted
, which is triggered whenever a blip is submitted (duh!). Many robots are more eager
and subscribe to document_changed
, which is invoked as a user types. For now, I am happy to wait for the robot to kick
in once the blip is "done". The capabilities.xml
file, deployed under the relative path /_wave
looks as follows:
<?xml version="1.0" encoding="utf-8"?> <w:robot xmlns:w="http://wave.google.com/extensions/robots/1.0"> <w:capabilities> <w:capability name="blip_submitted" content="true" /> </w:capabilities> <w:version>0.33</w:version> </w:robot>
The servlet itself is relatively simple. It inherits from the AbstractRobotServlet
base class and overrides the processEvents
method:
public class WaveServlet extends AbstractRobotServlet { @Override public void processEvents(RobotMessageBundle events) { if (events.wasSelfAdded()) { Wavelet wavelet = events.getWavelet(); Blip blip = wavelet.appendBlip(); TextView textView = blip.getDocument(); textView.append("Trademark Bot listening..."); return; } List<Event> submittedEvents = events.getBlipSubmittedEvents(); for (Event e : submittedEvents ) { Blip blip = e.getBlip(); if (!blip.getCreator().equals("your robot")) { TextView textView = blip.getDocument(); replaceWords(dictionary, textView); } } }
The method responds to two types of events: WAVELET_SELF_ADDED and BLIP_SUBMITTED. We did not actually subscribe to the former, but it arrives by default whenever the robot is added to a wave. In response, the robot appends a new blip and populates the blip text with a simple greeting. The "meat" of the robot is in the remainder of the method, which loops over each submitted blip. If the blip is not submitted by this robot (trying to stay out of infinite loops), it replaces occurrences of words based on a dictionary. I hacked this together really quickly, so the algorithm replaces only the first occurrence of each word, so don't repeat yourself too much. I guess should have written a test first.
final static Map<String, String> dictionary = new HashMap<String, String>(); static { dictionary.put("Java", "Java(TM) based technology"); dictionary.put("Microsoft", "Micro$oft"); } boolean isSeparator(char c) { return Character.isWhitespace(c) || ",.;!\"".indexOf(c) >= 0; } void replaceWords(Map<String, String> dictionary, TextView textView) { String text = textView.getText(); for (String word : dictionary.keySet()) { int pos = text.indexOf(word); if (pos >= 0) { // make sure this is a word if (pos > 0 && !isSeparator(text.charAt(pos-1))) { break; } if (pos < text.length()-1 && !isSeparator(text.charAt(pos + word.length()))) { break; } textView.replace(new Range(pos, pos + word.length()), dictionary.get(word)); text = textView.getText(); } } }
A couple of items to note: the BLIP_SUBMITTED event appears to be triggered when a new blip is submitted or when an existing one is edited. Unfortunately the JavaDoc for the event constants is still empty, so it's better to run a quick check to make sure your robot gets the events you were expecting. Also, my robot does not work on the Wave title. I would have to subscribe to WAVELET_TITLE_CHANGED to catch this event.
The rest of the robot is standard servlet fare. You need a web.xml file, which maps
relative URL's to servlets. The robot servlet has to live under the relative path
/_wave/robot/jsonrpc
. The name of the robot as a wave participant is derived from your App Engine application
name, meaning a robot at xyz.appspot.com will be known to the wave sandbox as xyz@appspot.com.
Somehow, it became a quick fashion to name all robots ending in a ""y", giving it
a cute-y name like "spelly" or "groupy". Following this rule I should have named mine
"replacey", but I felt it sounds too dorky.
This robot is very simple, but it still took me some time to get it working. In the end it turned out I had some nasty version mismatches between the Wave Robot SDK, the App Engine run-time and the Eclipse plug-in. When your robot does not seem to receive the desired event or does not respond at all, things can be pretty frustrating. Once you have a basic robot working, debug and test cycles are relatively swift thanks to the easy deployment of App Engine applications. So I'd always recommend to start with a "Hello World" robot and go from there. The bottom of the Wave Robot Api Overview has some useful troubleshooting tips. If your robot is not well, it's best is to check out the App Engine application logs, which are available from the App Engine console. For example, my logs alerted me to missing object ID's on my JDO classes.
To make our robot a bit more useful (and interesting), I decided to give it a dictionary data store, meaning you can define your own terms and chose their replacements. Replacing Java with Java(TM) will be just one of many useful replacements. For example, you could replace the phrase "Moron!" with "Thank you very much for your suggestion. I will be sure to bring up your feedback at our next internal meeting." This way you get all the satisfaction without losing political capital.
App Engine for Java supports JPA and JDO for persistence. Having successfully escaped
from both throughout my career, I decided to see what it would take to get JDO-based
persistence working for someone who has no clue. The good news is that this part was
pretty pain free. The App Engine Eclipse plug-in deploys the right jar files (assuming
you have matching versions of the plug-in and SDK) and sets up a jdoconfig.xml
file for you.
I setup a simple immutable value object for each dictionary entry, equipped it with a primary key, and made it persistable:
@PersistenceCapable(identityType = IdentityType.APPLICATION) public class DictEntry { @PrimaryKey @Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY) private Long id; @Persistent private String key; @Persistent private String value; public DictEntry(String key, String value) { super(); this.key = key; this.value = value; } public String getKey() { return key; } public String getValue() { return value; } }
Now I can load my dictionary from the persistent data store:
Map<String,String> loadDictionary() { Map<String,String> dict = new HashMap<String,String>(); PersistenceManager pm = PMF.get().getPersistenceManager(); Query query = pm.newQuery(DictEntry.class); try { List<DictEntry> results = (List<DictEntry>) query.execute(); if (results.iterator().hasNext()) { for (DictEntry e : results) { dict.put(e.getKey(), e.getValue()); } } } finally { query.closeAll(); pm.close(); } return dict; }
PMF
is a singleton PersistenceManagerFactory
. The rest of the code is pretty self-explanatory: it inserts one entry into the
map for each row in the data store.
Loading the dictionary from the database is only useful if you can also insert data:
boolean addToDict(String key, String value) { deleteFromDict(key); DictEntry entry = new DictEntry(key, value); PersistenceManager pm = PMF.get().getPersistenceManager(); try { pm.makePersistent(entry); return true; } catch (Exception e) { return false; } finally { pm.close(); } }
deleteDict
iterates over all entries matching the key and deletes them one by one. In order
to enable management of the persistent dictionary, I made the robot understand and
respond to a simple set of commands, #help, #replace, #showdict
. If a blip starts with such a command, the robot responds with a child blip by calling
createChild
on the blip. I had some struggles getting the output formatted in any reasonable
way. The docs state that some HTML is allowed, but did not indulge us in which tags
are part of that elite club. I got tired of trying pretty quickly, so the output looks
pretty raw. What really tripped me up was that TextView.append
does simply nothing if the string contains a "greater than" sign. You'll have to
use >
instead, so it does not think you are trying to do some invalid HTML. It would have
been nice to mention that in the docs. Ironically, the supplied JavaDoc for appendMarkup
makes a similar mistake by also forgetting to escape special characters, causing
the generated JavaDoc HTML page to interpret the tags instead of displaying them as
text. When I was trying to figure out why my test is now appended to the TextView
, I figured I'd simply check out the source code, but most of the methods of the robot
API simply translate the Java call into an instance of Operation
, which is then serialized and returned to the Wave server. This means looking at
the robot API source does not really help you answer detailed questions like these.
Because you can invite more than one robot into a wave, you can inadvertently end up building a new version of Robot Wars. For example, one robot could reply to another one's changes and vice versa. The current solution is to give robots limited quota, so infinite loops will ultimaltely run out of steam.
Now that everything is working, you can put the icing on the cake and give our robot
a profile servlet. This servlet equips the robot with a proper name and an avatar
by overriding the respective methods of the ProfileServlet
base class. Map this servlet to /_wave/robot/profile
in web.xml
and you are good to go. I chose a picture I took of the live size Gundam, which is currently on display in Odaiba, Tokyo. Robots don't get much cooler than
that.