Integrating Twitter and WCS: A Primer On Asset Event Listeners And Other Mechanisms
Welcome back!
In my previous article, I outlined the different mechanisms in WCS for plugging your own ad-hoc logic into the asset save processing flow.
Before diving into the subject at hand, here are a couple of things we are NOT going to resolve in this article:
- The "best possible" asset model for supporting Twitter integration.
- Asset model, like any data model, varies significantly depending on each project's specific set of requirements. What could be the ideal for a given project, could be the totally inappropriate for a different one.
- The asset model we use in this blog entry is, essentially, the simplest one we could use in order to illustrate the concepts at hand.
- The "best way" Twitter and WCS can be integrated.
- Just as for asset models, there are multiple ways you could integrate those 2, all of them potentially valid.
- Throughout this blog entry, we will describe one possible approach and, in some specific areas, we may mention viable alternatives, if such -- but without detailing those.
So:
- If you want to integrate Twitter and WCS and are unsure about how to approach that for your specific scenario, don't expect this article architecting the whole thing for you. Just give us a call at Function1, we'll be more than happy to help you out! :)
- If you are just harvesting ideas from here and there so to make up your mind and design your own path (or maybe you are just curious as to what's the bare minimum it takes integrating Twitter and WCS), then keep reading and have fun!
Hopefully, the basic concepts we outline in these 2 articles will give you a headstart on one of the multiple ways WCS can be integrated with your 3rd party service and/or platform of preference: YouTube, Google Maps, Facebook, Instagram, etc...
(DISCLAIMER: WCS' Proxy assets have a lot to say on integrating WCS with 3rd party platforms / services, but we won't be covering those in this article)
SPECS
These are the (very basic) self-imposed specs driving the sample implementation this article is based upon:
- A tweet must be generated when creating and updating an asset.
- A tweet must be generated only after the asset's data has been saved.
- A tweet must be deleted only when the related asset has been successfully deleted (e.g. saved with status='VO')
- A tweet may comprise some text, an image or both.
- The solution must be backwards-compatible with WCS 11g.
ASSET MODEL
In order to keep the example as simple as possible, we opted for reutilizing the "AVIArticle" and "AVIImage" asset types / definition from the "avisports" sample site included in the WCS 11g JumpStartKit (JSK).
We did not alter the "AVIImage" flex definition in any way.
We added the following attributes to the "AVIArticle" flex definition:
- mustTweetOnCreate ("true" / "false"): flags if a tweet (status) must be generated for the asset in question when it gets created (i.e. upon save)
- mustTweetOnUpdate ("true" / "false"): flags if a tweet (status) must be generated for the asset in question when it gets updated (i.e. upon save)
- tweetStatus: an attribute for content editors specifying the text to be tweeted.
We did not create a specific flex definition for "standalone" (as in "not related to any particular assets") tweets since it didn't add much to the concepts we could already demonstrate by reusing the existing flex definitions, as per above. Having said that, based on experience, doing so definitively make sense in many real life scenarios.
We created a custom table, "TweetToAssetMap", where we bind together an asset's ID (and type) and each tweet produced out of its own data.
For simplicity reasons, we didn't invest any time implementing custom attribute editors; say, for validating the length of the "tweetStatus" string.
TWITTER CONFIG
In order to keep the example as simple as possible, we hardcoded the due twitter credentials into our Java code.
We did not define any custom property files / properties or even a custom flex definition so the twitter channel's (or channels') credentials could be configured / modified without that requiring any code changes.
In a real life implementation we would definitively manage the twitter credentials (e.g. consumer key / secret, access token / secret) by relying on either of those 2 resources.
For instance, we could define a "TwitterChannelConfig" asset where these settings could be defined by a "Privileged" (WCS) user, which would enable "Ordinary" (WCS) users (e.g. content authors) specifying which Twitter Channel(s) a given content asset's tweets should go to (for ex.: by binding them together via a multivalued "asset" attribute)
For the sake of simplicity, we used a single Twitter account / channel for all use cases, MANAGEMENT-specific and DELIVERY-specific. Obviously, in a real life implementation we would need at least 2 Twitter accounts: one for MANAGEMENT (e.g. for testing), and one for DELIVERY (e.g. for the LIVE site).
THE SOLUTION
For MANAGEMENT instances:
- A custom Asset Event Listener provides the hook-up point for generating a tweet every time an "AVIArticle" asset is saved (created / updated / deleted)
For DELIVERY instances:
- A custom CacheUpdater provides the hook-up points for both generating a tweet every time one or more "AVIArticle" assets are published (RealTime).
Tweet generation and Tweet-To-Asset binding (/ unbinding) occurs asynchronously, by means of an in-memory queue, in order to avoid the asset save operation getting blocked over any network and/or service-related issues. This approach also minimizes any impact on the performance of the asset save (/delete) operation deriving from the custom logic being plugged into it.
Tweet-to-Asset bindings don't get stored in the asset itself (e.g. as a flex attribute) but on a completely separate, custom (Object) table we've created for that specific purpose: TweetToAssetMap.
We've opted for this approach due to:
- Its favoring simpler, cleaner architecture,
- Its allowing for cleaner, simpler, better encapsulated coding,
- Its favoring more efficient processing logic,
- Its being the alternative that allowed deleting tweets upon publishing in a truly transactional way -- i.e. only when we are sure that the published data will not be rolled back for any of the published assets (see specs above)
- Binding the last twitter status' ID to the concerned asset (and saving that asset) inside the asset event listener itself can crash the asset event listener due to transactional issues arising in WCS' asset API triggered by nested updates to the same asset.
- Amongst other reasons...
Messages are pushed into the queue from within the Asset Event Listener (a.k.a. the producer) and are pulled out from the queue by a custom SystemEvent + custom code element (a.k.a. the consumer) which is executed every 60 seconds and is capped at 10 messages / execution.
The core of the consumer's logic is encapsulated inside a Java class: TwitterStatusBindingEvent.
For the sake of simplicity, we use an very simple in-memory queue implementation (Java). We won't provide any details around our in-memory queue since that's irrelevant for this article's purpose.
In any case, the queue's actual implementation is abstracted by a TwitterStatusQueueManager Java class.
The consumer:
- Pulls a message from the queue.
- Determines if it's a "Tweet, Bind and Save" message or a "Delete Tweet" message.
- Depending on the kind of message, it executes the message-specific logic.
- It stops either due to there not being any more messages to process or as soon as the cap (e.g. 10 messages) is reached.
At this point, you probably have some fundamental questions about our approach that we haven't answered yet:
- Why did you use an asset event listener instead of a flex filter or a custom CacheUpdater?
- Why didn't you use PreUpdate or PostUpdate?
- Why a custom CacheUpdater instead of the same asset event listener?
- etc...
OUR ASSET EVENT LISTENER
The source code of our asset event listener is, in general, straightforward. That has to do with 2 things:
1) This is just an exercise, not a real life project :)
2) It normally doesn't get a lot more complex than this when specs are along the lines of those we outlined above.
package com.function1.sample.twitter.wcs.asset; import COM.FutureTense.Interfaces.ICS; import com.fatwire.assetapi.common.AssetAccessException; import com.fatwire.assetapi.data.*; import com.function1.sample.twitter.queue.TwitterStatusQueueManager; import com.function1.sample.twitter.queue.TwitterStatusToAssetBindingMessage; import com.function1.sample.twitter.utils.AssetUtils; import com.openmarket.basic.event.AbstractAssetEventListener; public class TwitterEventListener extends AbstractAssetEventListener { private ICS myICS; @Override public void init(ICS ics) { this.myICS = ics; } @Override public void assetAdded(AssetId theAsset) { // The logic below can be as sophisticated as needed. // For the sake of this example, we just check if the // asset being created must be tweeted or not as per // the "mustTweetOnCreate" attribute try { if (AssetUtils.mustTweetOnCreate(theAsset, this.myICS)) { TwitterStatusToAssetBindingMessage messageBean = new TwitterStatusToAssetBindingMessage(theAsset.getId(), theAsset.getType()); messageBean.setAction(TwitterStatusToAssetBindingMessage.ACTION_TWEET_BIND_AND_SAVE); TwitterStatusQueueManager.getInstance().push(messageBean); } else { System.out.println("Asset " + theAsset + " must not be tweeted on create."); } } catch (AssetAccessException aae) { // Avoid the exception breaking the whole save operation (if that's what you need) } } @Override public void assetDeleted(AssetId theAsset) { // The logic below cannot be asset-specific since // by the time you get here, the asset's daa has been // already wiped out -- that is, unless you've copied // the asset data you need somewhere else outside the // asset-specific tables. if (AssetUtils.mustTweetOnDelete(theAsset, myICS)) { TwitterStatusToAssetBindingMessage messageBean = new TwitterStatusToAssetBindingMessage(theAsset.getId(), theAsset.getType()); messageBean.setAction(TwitterStatusToAssetBindingMessage.ACTION_DELETE_TWEET); TwitterStatusQueueManager.getInstance().push(messageBean); } else { System.out.println("Listener bypassed... only applies to AVIArticle and Page assets."); } } @Override public void assetUpdated(AssetId theAsset) { // The logic below can be as sophisticated as needed. // For the sake of this example, we just check if the // asset being created must be tweeted or not as per // the "mustTweetOnUpdate" attribute try { if (AssetUtils.mustTweetOnUpdate(theAsset, myICS)) { TwitterStatusToAssetBindingMessage messageBean = new TwitterStatusToAssetBindingMessage(theAsset.getId(), theAsset.getType()); messageBean.setAction(TwitterStatusToAssetBindingMessage.ACTION_TWEET_BIND_AND_SAVE); TwitterStatusQueueManager.getInstance().push(messageBean); } else { System.out.println("Asset " + theAsset + " must not be tweeted on update."); } } catch (AssetAccessException aae) { // Avoid the exception breaking the whole save operation (if that's what you need) } } }
As you can see, all it really does is push a message into the queue, whenever appropriate.
As you can see, we've encapsulated some supporting code in separate classes, of course:
- TwitterStatusQueueManager, which we've already discussed above.
- AssetUtils, which we haven't discussed but (hopefully) it is very simple to infer what it does by just looking at the method names.
At this point, we can almost hear you saying: "Oh... but if you want your custom logic to be invoked in a 100% transactionally-safe manner, this won't cover it, you should have used the asset type-specific PostUpdate element".
Keep reading. :)
ASSET EVENT LISTENER vs PostUpdate vs CACHE UPDATER
For STAGING (a.k.a. MANAGEMENT) instances, the only alternative from the 5 above that truly certifies that asset's data has been irrevocably committed is plugging your custom logic onto the asset type-specific PostUpdate element.
However:
- Asset Event Listeners always get invoked whenever an asset is saved, regardless what triggers it (Contributor UI, Admin UI, XMLPost, REST API, etc...). PostUpdate is not invoked always (for ex.: REST API)
- In general, the WCS Java API's contract is more reliable and stable than element-driven mechanisms.
- Tweets in a MANAGEMENT instance are pressumed to be private / merely for testing purposes -- as opposed to those you'd generate upon publishing onto the LIVE site.
These and all other motives we had already outlined in previous subsections as well as my previous article make the case for our using an Asset Event Listener for hooking up our custom logic.
If your own specs required your being 100% sure that your custom logic is never invoked before an asset save's transaction is committed and you are OK with your custom logic not getting invoked when the save operation is triggered via REST API, then your only choice - on a MANAGEMENT instance - is to plugging it into a custom PostUpdate element instead of an asset event listener.
For DELIVERY (e.g. LIVE) instances, the only (supported) alternative that is 100% transactionally safe is plugging your custom logic into the cache flush phase of RealTime publishing sessions by means of a custom CacheUpdater.
OUR CACHE UPDATER
The source code of our cache updater - WcsToTwitterCacheUpdaterImpl - is almost a raw copy of our asset event listener's source code.
Below is a snippet (DISCLAIMER: untested) of the truly crucial method driving the whole thing. For the sake of simplicity, we've extended ParallelRegeneratorEh.
In real life projects, there are other design patterns for implementing Cache Updaters that may be better suited than plain extension, depending on specific requirements, but that discussion is off this article's scope.
package com.function1.sample.twitter.wcs.realtime; (some imports...) public class WcsToTwitterCacheUpdaterImpl extends ParallelRegeneratorEh { public static final String ASSET_STATUS_CREATED = "PL"; public static final String ASSET_STATUS_VOIDED = "VO"; private static final Object ASSET_STATUS_EDITED = "ED"; private static final Object ASSET_STATUS_RECEIVED = "RF"; private static final Object ASSET_STATUS_UPGRADED = "UP"; @Override protected void beforeSelect(ICS ics, CollectioninvalKeys, Collection regenKeys, Collection assetIds) { if ((assetIds != null) && !assetIds.isEmpty()) { // Implement custom stuff for (AssetId assetId : assetIds) { String status = AssetUtils.getStatusForAsset(assetId.getType(), assetId.getId(), ics); if (ASSET_STATUS_VOIDED.equals(status)) { assetDeleted(assetId, ics); } else if (ASSET_STATUS_CREATED.equals(status)) { assetAdded(assetId, ics); } else if (ASSET_STATUS_EDITED.equals(status)) { assetUpdated(assetId, ics); } else if (ASSET_STATUS_RECEIVED.equals(status) || ASSET_STATUS_UPGRADED.equals(status)) { if (AssetUtils.createdDateMatchesUpdatedDate(assetId.getType(), assetId.getId(), ics)) { assetAdded(assetId, ics); } else { assetUpdated(assetId, ics); } } else { System.out.println("status '" + status + "' is not supported. Asset " + assetId + " will not get tweeted."); } } } super.beforeSelect(ics, invalKeys, regenKeys, assetIds); } (...) }
Essentially, all we do is determine if each published asset has been just created, updated or deleted and, based on that, invoke the right method for pushing the appropriate message into the messaging queue.
OUR CUSTOM SystemEvent (& DELEGATE CONSUMER)
The consumer is invoked by a SystemEvent (daemon) which triggers every 60 seconds. Source code below.
<%@ taglib prefix="cs" uri="futuretense_cs/ftcs1_0.tld" %> <%@ taglib prefix="ics" uri="futuretense_cs/ics.tld" %> <%@ taglib prefix="satellite" uri="futuretense_cs/satellite.tld" %> <%@ taglib prefix="usermanager" uri="futuretense_cs/usermanager.tld" %> <%// // Function1/Samples/Twitter/TwitterStatusToAssetBinder // // INPUT // // OUTPUT //%> <%@ page import="COM.FutureTense.Interfaces.FTValList" %> <%@ page import="COM.FutureTense.Interfaces.ICS" %> <%@ page import="COM.FutureTense.Interfaces.IList" %> <%@ page import="COM.FutureTense.Interfaces.Utilities" %> <%@ page import="COM.FutureTense.Util.ftErrors" %> <%@ page import="COM.FutureTense.Util.ftMessage"%> <cs:ftcs><% %><ics:getproperty name="xcelerate.batchuser" file="futuretense_xcel.ini" output="batchusername"/><% %><ics:getproperty name="xcelerate.batchpass" file="futuretense_xcel.ini" output="batchpassword"/><% %><usermanager:loginuser username='<%=ics.GetVar("batchusername")%>' password='<%=Utilities.decryptString(ics.GetVar("batchpassword"))%>' varname="loggedIn" /><% %><ics:if condition='<%="true".equalsIgnoreCase(ics.GetVar("loggedIn"))%>'><% %><ics:then><% System.out.println("Just entered TwitterStatusToAssetBinder SystemEvent..."); int bound = com.function1.sample.twitter.wcs.events.TwitterStatusBindingEvent.run(ics); System.out.println("TwitterStatusToAssetBinder event: " + bound + " bindings were done."); %></ics:then><% %></ics:if><% %><usermanager:logout /><% %></cs:ftcs>
As you can see, all it really does is log into the system and then it delegates right away onto a Java class - TwitterStatusBindingEvent - where the "meat" of consumer really is.
The source code of this consumer is actually pretty straightforward:
package com.function1.sample.twitter.wcs.events; (a whole bunch of imports...) public class TwitterStatusBindingEvent { private static final String oAuthConsumerKey = TwitterCredentials.oAuthConsumerKey; private static final String oAuthConsumerSecret = TwitterCredentials.oAuthConsumerSecret; private static final String oAuthAccessToken = TwitterCredentials.oAuthAccessToken; private static final String oAuthAccessTokenSecret = TwitterCredentials.oAuthAccessTokenSecret; private static final int MAX_MESSAGES_PER_EXECUTION = 10; private static long doTweetBindAndSave(ICS myICS, TwitterStatusToAssetBindingMessage messageBean) { AssetId theAsset = new AssetIdImpl(messageBean.getAssetType(), messageBean.getAssetId()); Session ses = SessionFactory.getSession(myICS); AssetDataManager adm = (AssetDataManager) ses.getManager(AssetDataManager.class.getName()); try { // Extract the twitterStatus from the asset // referenced in the message ListattrNames = new ArrayList (); attrNames.add("twitterStatus"); attrNames.add("relatedImage"); AssetData data = adm.readAttributes(theAsset, attrNames); String text = (String) data.getAttributeData("twitterStatus").getData(); // Extract the relatedImage from the asset // referenced in the message File myImageFile = null; if (theAsset.getType().equals("AVIArticle")) { AssetData articleData = adm.readAttributes(theAsset, attrNames); AttributeData imageAssetIdData = articleData.getAttributeData("relatedImage"); if ((imageAssetIdData != null) && (imageAssetIdData.getData() != null)) { AssetId imageAsset = (AssetId) imageAssetIdData.getData(); attrNames = new java.util.ArrayList (); attrNames.add("imageFile"); AssetData imageData = adm.readAttributes(imageAsset, attrNames); AttributeData docAttr = imageData.getAttributeData("imageFile"); if ((docAttr != null) && (docAttr.getData() != null)) { BlobObject fileObj = (BlobObject) docAttr.getData(); String blobPath = myICS.GetProperty("cc.urlattrpath", "gator.ini", false); myImageFile = new File(blobPath + fileObj.getFilename()); } else { System.out.println("No file bound to imageFile attribute of asset " + imageAsset); } } else { System.out.println("No image asset bound to article " + theAsset); } } else { System.out.println("This is just a sample, supports images for AVIArticle assets only."); } // Tweet! long statusId = TwitterService.syncTweet( text, myImageFile, oAuthConsumerKey, oAuthConsumerSecret, oAuthAccessToken, oAuthAccessTokenSecret); if (statusId > 0) { // Bind twitterStatusId to asset TweetToAssetMapHelper.map(theAsset.getType(), theAsset.getId(), Long.toString(statusId), myICS); return statusId; } else { System.out.println("Unexpected error (status = " + statusId + ") whilst tweeting for asset " + theAsset); return -1; } } catch (Exception e) { e.printStackTrace(); return -1; } } private static void doDeleteTweets(ICS myICS, TwitterStatusToAssetBindingMessage messageBean) { try { List lastTwitterStatusIds = TweetToAssetMapHelper.getAllTwitterStatusIdsForAsset(messageBean.getAssetType(), messageBean.getAssetId(), myICS); if ((lastTwitterStatusIds != null) && (lastTwitterStatusIds.size() > 0)) { Iterator it = lastTwitterStatusIds.iterator(); while (it.hasNext()) { long twitterStatusId = Long.parseLong(it.next()); boolean deleted = TwitterService.syncDeleteTweet( twitterStatusId, oAuthConsumerKey, oAuthConsumerSecret, oAuthAccessToken, oAuthAccessTokenSecret); if (deleted) { System.out.println("TwitterStatusBinding.doDeleteTweet: DELETED Twitter Status " + twitterStatusId); } else { System.out.println("TwitterStatusBinding.doDeleteTweet: Could NOT delete Twitter Status " + twitterStatusId); } } TweetToAssetMapHelper.unmapByAsset(messageBean.getAssetType(), messageBean.getAssetId(), myICS); } else { System.out.println("TwitterStatusBindingEvent.doDeleteTweet: no tweets to be deleted for asset " + messageBean.getAssetType() + ":" + messageBean.getAssetId()); } } catch (Exception e) { System.out.println("TwitterStatusBindingEvent.doDeleteTweet: unexpected error, cannot delete tweet."); e.printStackTrace(); } } public static synchronized int run(ICS ics) { int out = 0; TwitterStatusQueueManager mgr = TwitterStatusQueueManager.getInstance(); if (mgr != null) { int messagesCounter = 0; TwitterStatusToAssetBindingMessage messageBean; while ((messagesCounter <= MAX_MESSAGES_PER_EXECUTION) && ((messageBean = mgr.pull()) != null)) { if (messageBean.getAction().equals(TwitterStatusToAssetBindingMessage.ACTION_TWEET_BIND_AND_SAVE)) { doTweetBindAndSave(ics, messageBean); } else if (messageBean.getAction().equals(TwitterStatusToAssetBindingMessage.ACTION_DELETE_TWEET)) { doDeleteTweets(ics, messageBean); } messagesCounter++; } } return out; } }
A NOTE ON ALL OTHER SUPPORTING CLASSES / COMPONENTS...
From all other classes and components supporting those described in greater detail above, the only method that probably deserves a brief mention is AssetUtils.getStatusForAsset(String assetType, long assetId, ICS ics).
All this method does is extracting from a specified asset the value of the "status" column on that asset's main table (e.g. the "asset type" table).
It does not use any of the asset API's classes but a simple query executed via ics.SQL method.
The reasons we did so were:
- That outperforms any other approach, especially since you get to benefit from the ResultSet caching mechanism built into WCS.
- For all assets, especially deleted assets, this is by far the simplest way to retrive this info.
WRAPPING IT UP...
As you can see, we haven't put a lot of effort in error handling (hey, this is just a sample!).
That said, this should be more than enough for illustrating the concepts and allowing your ellaborating on top of them by creating a truly robust solution -- certainly one that is as complex as needed by your project's business and technical requirements.
These are just some ideas on how the above implementation could be extend and "professionalized":
- Proper error handling.
- Proper messaging architecture (e.g. JMS and/or DB)
- Integrate Twitter credentials' management into WCS (for example: by assetizing it)
- Multi-Twitter channel support (e.g. content authors specifying which Twitter channel(s) a given Tweet should be published to)
- "Standalone" Twitter asset which allows tweeting without any regards to any content asset (e.g. content author specifies text and image instead of extracting them from a content asset)
- etc... :)
That's all. Thanks for reading!
- Log in to post comments
Comments
Joe Scanlon on April 15, 2017
Nice write up Freddy!
Freddy on April 25, 2017
Thanks Joe!