Java tutorial
package net.storyspace; import java.io.IOException; import java.io.InputStream; import java.io.Serializable; import java.lang.reflect.Field; import java.math.BigInteger; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.EnumMap; import java.util.GregorianCalendar; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import net.storyspace.StorySpaceException.E401; import net.storyspace.StorySpaceException.E403; import net.storyspace.StorySpaceException.SuspendedUser; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; /** * Java wrapper for the StorySpace API version {@value #version} * <p> * Example usage:<br> * First, you should get the user to authorise access via OAuth. There are a * couple of ways of doing this -- we show one below -- see * {@link OAuthSignpostClient} for more details. * <p> * Note that you don't need to do this for some operations - e.g. you can look * up public posts without logging in (use the {@link #StorySpace()} constructor. * * <code><pre> // First, OAuth to login: Make an oauth client OAuthSignpostClient oauthClient = new OAuthSignpostClient(JStorySpace_OAUTH_KEY, JStorySpace_OAUTH_SECRET, "oob"); // open the authorisation page in the user's browser oauthClient.authorizeDesktop(); // Note: this only works on desktop PCs // or direct the user to the webpage given jby oauthClient.authorizeUrl() oauthClient.setAuthorizationCode(v); // You can store the authorisation token details for future use Object accessToken = client.getAccessToken(); </pre></code> * * Now we can access StorySpace: <code><pre> // Make a StorySpace object StorySpace StorySpace = new StorySpace("my-name", oauthClient); // Print Acellam Guy's status System.out.println(StorySpace.getStatus("abiccel@yahoo.com")); // Set my status StorySpace.updateStatus("I love StorySpaces"); </pre></code> * * <p> * If you can handle callbacks, then the OAuth login can be streamlined. You * need a webserver and a servlet (eg. use Jetty or Tomcat) to handle callbacks. * Replace "oob" with your callback url. Direct the user to * client.authorizeUrl(). StorySpace will then call your callback with the request * token and verifier (authorisation code). * </p> * <p> * See {@link http://www.dev.storyspaces.net/software/jStorySpace} for more * information about this wrapper. See {@link http * ://apiwiki.StorySpace.com/StorySpace-API-Documentation} for more information about * the StorySpace API. * <p> * Notes: * <ul> * <li>This wrapper takes care of all url-encoding/decoding. * <li>This wrapper will throw a runtime exception (StorySpaceException) if a * methods fails, e.g. it cannot connect to StorySpace.com or you make a bad * request. * <li>Note that StorySpace treats old-style respeakups (those made by sending a * normal speakup beginning "RT @whoever") differently from new-style respeakups * (those made using the respeakup API). The differences are documented in various * methods. * <li>Most methods are in this class (StorySpace), except for list support (in * {@link StorySpaceList} - though {@link #getLists()} is here) and some * profile/account settings (in {@link StorySpaceAccount}). * </ul> * * <h4>Copyright and License</h4> * This code is copyright (c) storyspaces Inc and (c) storyspaces Inc * except where otherwise stated. It is released as * open-source under the LGPL license. See <a * href="http://www.gnu.org/licenses/lgpl.html" * >http://www.gnu.org/licenses/lgpl.html</a> for license details. This code * comes with no warranty or support. * * <h4>Change List</h4> * The change list is kept online at: {@link http * ://www.dev.storyspaces.net/software/changelist.txt} * * @author Acellam Guy */ public class StorySpace implements Serializable { private static final long serialVersionUID = 1L; /** * Matches latitude, longitude, including with the UberStorySpace UT: prefix * Group 2 = latitude, Group 3 = longitude */ public static final Pattern latLongLocn = Pattern.compile("(\\S+:)?\\s*(-?[\\d\\.]+),\\s*(-?[\\d\\.]+)"); public static enum KEntityType { urls, user_mentions, hashtags } /** * The different types of API request. These can have different * rate limits. */ public static enum KRequestType { NORMAL, SEARCH, SHOW_USER, /** this is X-Feature Class "namesearch" in the response headers*/ SEARCH_USERS } /** * The Access Token from the User */ public String atoken = ""; /** * A special slice of text within a speakup. * Status: experimental (for us and StorySpace) * @see StorySpace#setIncludespeakupEntities(boolean) */ public final static class speakupEntity implements Serializable { private static final long serialVersionUID = 1L; public final int start; public final int end; private final Ispeakup speakup; private final String display; public final KEntityType type; /** * @return For a url: the expanded version * For a user-mention: the user's name */ public String displayVersion() { return display == null ? toString() : display; } speakupEntity(Ispeakup speakup, KEntityType type, JSONObject obj) throws JSONException { JSONArray indices = obj.getJSONArray("indices"); this.start = indices.getInt(0); this.end = indices.getInt(1); this.speakup = speakup; this.type = type; switch (type) { case urls: Object eu = obj.get("expanded_url"); display = JSONObject.NULL.equals(eu) ? null : (String) eu; break; case user_mentions: display = obj.getString("name"); break; default: display = null; } } @Override public String toString() { return speakup.getText().substring(start, end); } static List<speakupEntity> parse(Ispeakup speakup, KEntityType type, JSONObject jsonEntities) throws JSONException { JSONArray arr = jsonEntities.optJSONArray(type.toString()); ArrayList<speakupEntity> list = new ArrayList<speakupEntity>(arr.length()); for (int i = 0; i < arr.length(); i++) { JSONObject obj = arr.getJSONObject(i); speakupEntity te = new speakupEntity(speakup, type, obj); list.add(te); } // "user_mentions":[{"id":19720954,"name":"Debbie Elzie","indices":[0,10],"screen_name":"DebbieElzie"} return list; } } /** * Change this to access sites other than StorySpace that support the StorySpace * API. */ String StorySpace_URL = "http://10.0.2.2:3000/api/genesis"; /** * Search has to go through a separate url (StorySpace's decision, June 2010). */ private static final String StorySpace_SEARCH_URL = "http://search.storyspace.net"; /** * Set this to access sites other than StorySpace that support the StorySpace API. * E.g. WordPress or Identi.ca. Note that not all methods may work! Also, * search uses a separate url and is not affected by this method (it will * continue to point to StorySpace). * * @param url * Format: "http://domain-name", e.g. "http://storyspace.net" by * default. */ public void setAPIRootUrl(String url) { assert url.startsWith("http://") || url.startsWith("https://"); assert !url.endsWith("/") : "Please remove the trailing / from " + url; StorySpace_URL = url; } /** * Use to register per-page callbacks for long-running searches. To stop the * search, return true. * */ public interface ICallback { public boolean process(List<Status> statuses); } /** * Interface for an http client - e.g. allows for OAuth to be used instead. * The standard version is {@link OAuthSignpostClient}. * <p> * If creating your own version, please provide support for throwing the * right subclass of StorySpaceException - see * {@link URLConnectionHttpClient#processError(java.net.HttpURLConnection)} * for example code. * * @author Acellam Guy */ public static interface IHttpClient { /** * Whether this client is setup to do authentication when contacting the * StorySpace server. Note: This is a fast method that does not call the * server, so it does not check whether the access token or password is * valid. See {StorySpace#isValidLogin()} or * {@link StorySpaceAccount#verifyCredentials()} if you need to check a * login. * */ boolean canAuthenticate(); /** * Send an HTTP GET request and return the response body. Note that this * will change all line breaks into system line breaks! * * @param uri * The uri to fetch * @param vars * get arguments to add to the uri * @param authenticate * If true, use authentication. The authentication method * used depends on the implementation (basic-auth, OAuth). It * is an error to use true if no authentication details have * been set. * * @throws StorySpaceException * for a variety of reasons * @throws StorySpaceException.E404 * for resource-does-not-exist errors */ String getPage(String uri, Map<String, String> vars, boolean authenticate) throws StorySpaceException; /** * Send an HTTP POST request and return the response body. * * @param uri * The uri to post to. * @param vars * The form variables to send. These are URL encoded before * sending. * @param authenticate * If true, send user authentication * @return The response from the server. * * @throws StorySpaceException * for a variety of reasons * @throws StorySpaceException.E404 * for resource-does-not-exist errors */ String post(String uri, Map<String, String> vars, boolean authenticate) throws StorySpaceException; /** * Set the timeout for a single get/post request. This is an optional * method - implementation can ignore it! * * @param millisecs */ void setTimeout(int millisecs); /** * Fetch a header from the last http request. * This is inherently NOT thread safe. * @param headerName * @return header value, or null if unset */ String getHeader(String headerName); HttpURLConnection connect(String url, Map<String, String> vars, boolean b) throws IOException; } /** * This gives common access to features that are common to both * {@link Message}s and {@link Status}es. * * @author daniel * */ public static interface Ispeakup extends Serializable { Date getCreatedAt(); /** * StorySpace IDs are numbers - but they can exceed the * range of Java's signed long. * * @return The StorySpace id for this post. This is used by some API * methods. This may be a Long or a BigInteger. */ Number getId(); /** The actual status text. This is also returned by {@link #toString()} */ String getText(); /** The User who made the speakup */ User getUser(); } /** * A StorySpace direct message. Fields are null if unset. * * TODO are there more fields now? check the raw json */ public static final class Message implements Ispeakup { private static final long serialVersionUID = 1L; /** * Equivalent to {@link Status#inReplyToStatusId} *but null by default*. * If you want to use this, you must set it yourself. The field is just * a convenient storage place. Strangely StorySpace don't report the * previous ID for messages. */ public Number inReplyToMessageId; /** * Tests by class=Message and speakup id number */ @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Message other = (Message) obj; return id.equals(other.id); } @Override public int hashCode() { return id.hashCode(); } /** * * @param json * @return * @throws StorySpaceException */ static List<Message> getMessages(String json) throws StorySpaceException { if (json.trim().equals("")) return Collections.emptyList(); try { List<Message> msgs = new ArrayList<Message>(); try { JSONArray arr = new JSONArray(json); for (int i = 0; i < arr.length(); i++) { JSONObject obj = arr.getJSONObject(i); Message u = new Message(obj); msgs.add(u); } } catch (Exception e) { JSONArray arr = new JSONArray("[" + json + "]"); for (int i = 0; i < arr.length(); i++) { JSONObject obj = arr.getJSONObject(i); Message u = new Message(obj); msgs.add(u); } } return msgs; } catch (JSONException e) { throw new StorySpaceException.Parsing(json, e); } } private final Date createdAt; private final Long id; private final User recipient; private final User sender; public final String text; /** * @param obj * @throws JSONException * @throws StorySpaceException */ Message(JSONObject obj) throws JSONException, StorySpaceException { // No need for BigInteger - yet // String _id = obj.getString("id_str"); // id = new BigInteger(_id==null? ""+obj.get("id") : _id); id = obj.getLong("id"); String _text = obj.getString("text"); text = unencode(_text); String c = jsonGet("created_at", obj); createdAt = new Date(c); sender = new User(obj.getJSONObject("sender"), null); // recipient - for messages you sent if (obj.has("recipient")) { recipient = new User(obj.getJSONObject("recipient"), null); } else { recipient = null; } } public Date getCreatedAt() { return createdAt; } /** * @return The StorySpace id for this post. This is used by some API * methods. * <p> * Note: this may switch to BigInteger in the future, if StorySpace * change their id numbering scheme. Use Number (which is a super-class * for both Long and BigInteger) if you wish to future-proof your code. */ public Long getId() { return id; } /** * @return the recipient (for messages sent by the authenticating user) */ public User getRecipient() { return recipient; } public User getSender() { return sender; } public String getText() { return text; } /** * This is equivalent to {@link #getSender()} */ public User getUser() { return getSender(); } @Override public String toString() { return text; } } /** * A StorySpace status post. .toString() returns the status text. * <p> * Notes: This is a finalised data object. It exposes its fields for * convenient access. If you want to change your status, use * {@link StorySpace#setStatus(String)} and * {@link StorySpace#destroyStatus(Status)}. */ public static final class Status implements Ispeakup { private static final long serialVersionUID = 1L; @Override public int hashCode() { return id.hashCode(); } /** * Tests by class=Status and speakup id number */ @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Status other = (Status) obj; return id.equals(other.id); } /** * Convert from a json array of objects into a list of speakups. * * @param json * can be empty, must not be null * @throws StorySpaceException */ static List<Status> getStatuses(String json) throws StorySpaceException { if (json.trim().equals("")) return Collections.emptyList(); try { List<Status> speakups = new ArrayList<Status>(); JSONArray arr = new JSONArray(json); for (int i = 0; i < arr.length(); i++) { Object ai = arr.get(i); if (JSONObject.NULL.equals(ai)) { continue; } JSONObject obj = (JSONObject) ai; Status speakup = new Status(obj, null); speakups.add(speakup); } return speakups; } catch (JSONException e) { throw new StorySpaceException.Parsing(json, e); } } /** * Search results use a slightly different protocol! In particular * w.r.t. user ids and info. * * @param searchResults * @return search results as Status objects - but with dummy users! The * dummy users have a screenname and a profile image url, but no * other information. This reflects the current behaviour of the * StorySpace API. */ static List<Status> getStatusesFromSearch(StorySpace tw, String json) { try { JSONObject searchResults = new JSONObject(json); List<Status> users = new ArrayList<Status>(); JSONArray arr = searchResults.getJSONArray("results"); for (int i = 0; i < arr.length(); i++) { JSONObject obj = arr.getJSONObject(i); String userScreenName = obj.getString("from_user"); String profileImgUrl = obj.getString("profile_image_url"); User user = new User(userScreenName); user.profileImageUrl = URI(profileImgUrl); Status s = new Status(obj, user); users.add(s); } return users; } catch (JSONException e) { throw new StorySpaceException.Parsing(json, e); } } public final Date createdAt; public final BigInteger id; /** The actual status text. */ public final String text; /** * Rarely null. * <p> * When can this be null?<br> * - If creating a "fake" speakup via * {@link Status#Status(User, String, long, Date)} and supplying a null * User! */ public final User user; /** * E.g. "web" vs. "im" * <p> * "fake" if this Status was made locally or from an RSS feed rather * than retrieved from StorySpace json (as normal). */ public final String source; /** * Often null (even when this Status is a reply). This is the * in-reply-to status id as reported by StorySpace. */ public final BigInteger inReplyToStatusId; private boolean favorited; /** * null, except for official respeakups when this is the original * respeakuped Status. */ private Status original; /** * Represents the number of times a status has been respeakuped using * _new-style_ respeakups. -1 if unknown. */ public final int respeakupCount; private EnumMap<KEntityType, List<speakupEntity>> entities; private String location; /** * @return the location of this speakup. Can be null. * This can come from geo-tagging or the user's location. * This may be a place name, or in the form "latitude,longitude" if * it came from a geo-tagged source. */ public String getLocation() { return location; } /** * Only set for official new-style respeakups. This is the original * respeakuped Status. null otherwise. */ public Status getOriginal() { return original; } /** * true if this has been marked as a favourite by the authenticating * user */ public boolean isFavorite() { return favorited; } /** * regex for @you mentions */ static final Pattern AT_YOU_SIR = Pattern.compile("@(\\w+)"); private static final String FAKE = "fake"; /** * @param object * @param user * Set when parsing the json returned for a User. null when * parsing the json returned for a Status. * @throws StorySpaceException */ Status(JSONObject object, User user) throws StorySpaceException { try { String _id = object.optString("id_str"); id = new BigInteger(_id == "" ? object.get("id").toString() : _id); String _text = jsonGet("text", object); text = unencode(_text); String c = jsonGet("created_at", object); createdAt = new Date(c); // source - sometimes encoded (search), sometimes not // (timelines)! String src = jsonGet("source", object); source = src.contains("<") ? unencode(src) : src; // respeakup? JSONObject respeakuped = object.optJSONObject("respeakuped_status"); if (respeakuped != null) { original = new Status(respeakuped, null); } String irt = jsonGet("in_reply_to_status_id", object); if (irt == null) { // StorySpace doesn't give in-reply-to for respeakups // - but since we have the info, let's make it available inReplyToStatusId = original == null ? null : original.getId(); } else { inReplyToStatusId = new BigInteger(irt); } favorited = object.optBoolean("favorited"); // set user if (user != null) { this.user = user; } else { JSONObject jsonUser = object.optJSONObject("user"); // null user happens in very rare circumstances, which I // have not pinned down yet. if (jsonUser == null) { this.user = null; } else if (jsonUser.length() < 3) { // TODO seen a bug where the jsonUser is just {"id":24147187,"id_str":"24147187"} // Not sure when/why this happens String _uid = jsonUser.optString("id_str"); BigInteger userId = new BigInteger(_uid == "" ? object.get("id").toString() : _uid); try { user = new StorySpace().show(userId); } catch (Exception e) { // ignore } this.user = user; } else { // normal JSON case this.user = new User(jsonUser, this); } } // location if geocoding is on JSONObject geo = object.optJSONObject("geo"); location = object.optString("location"); if (location != null) { // normalise UT (UberStorySpace?) locations Matcher m = latLongLocn.matcher(location); if (m.matches()) { location = m.group(2) + "," + m.group(3); } } if (geo != null && geo != JSONObject.NULL && location == null) { JSONArray latLong = geo.getJSONArray("coordinates"); location = latLong.get(0) + "," + latLong.get(1); } respeakupCount = object.optInt("respeakup_count", -1); // ignore this as it can be misleading: true is reliable, false // isn't // respeakuped = object.optBoolean("respeakuped"); // Entities JSONObject jsonEntities = object.optJSONObject("entities"); if (jsonEntities != null) { // Note: StorySpace filters out dud @names entities = new EnumMap<StorySpace.KEntityType, List<speakupEntity>>(KEntityType.class); for (KEntityType type : KEntityType.values()) { List<speakupEntity> es = speakupEntity.parse(this, type, jsonEntities); entities.put(type, es); } } } catch (JSONException e) { throw new StorySpaceException.Parsing(null, e); } } /** * Create a *fake* Status object. This does not represent a real speakup! * Uses: few and far between. There is no real contract as to how * objects made in this way will behave. * <p> * If you want to post a speakup (and hence get a real Status object), use * {@link StorySpace#setStatus(String)}. * * @param user * Can be null or bogus -- provided that's OK with your code. * @param text * Can be null or bogus -- provided that's OK with your code. * @param id * Can be null or bogus -- provided that's OK with your code. * @param createdAt * Can be null -- provided that's OK with your code. */ @Deprecated public Status(User user, String text, Number id, Date createdAt) { this.text = text; this.user = user; this.createdAt = createdAt; this.id = id == null ? null : (id instanceof BigInteger ? (BigInteger) id : new BigInteger(id.toString())); inReplyToStatusId = null; source = FAKE; respeakupCount = -1; } public Date getCreatedAt() { return createdAt; } /** * @return The StorySpace id for this post. This is used by some API * methods. */ public BigInteger getId() { return id; } /** * @return list of \@mentioned people (there is no guarantee that these * mentions are for correct StorySpace screen-names). May be empty, * never null. Screen-names are always lowercased. */ public List<String> getMentions() { Matcher m = AT_YOU_SIR.matcher(text); List<String> list = new ArrayList<String>(2); while (m.find()) { // skip email addresses (and other poorly formatted things) if (m.start() != 0 && text.charAt(m.start() - 1) != ' ') continue; String mention = m.group(1); // enforce lower case list.add(mention.toLowerCase()); } return list; } /** * <i>Experimental</i>: StorySpace have announced they plan to wrap all urls * with their own url-shortener (as a defence against malicious speakups). * You are recommended to direct people to the StorySpace-url, but use the * original url for display. * * @param type urls, user_mentions, or hashtags * @return the text entities in this speakup */ public List<speakupEntity> getspeakupEntities(KEntityType type) { return entities == null ? null : entities.get(type); } /** The actual status text. This is also returned by {@link #toString()} */ public String getText() { return text; } public User getUser() { return user; } /** * @return The text of this status. E.g. "Kicking fommil's arse at * Civilisation." */ @Override public String toString() { return text; } } /** * This rather dangerous global toggle switches off lower-casing * on StorySpace screen-names. * <p> * Screen-names are case insensitive as far as StorySpace is concerned. * However you might want to preserve the case people use * for display purposes. * <p> * false by default. */ public static boolean CASE_SENSITIVE_SCREENNAMES; /** * A StorySpace user. Fields are null if unset. * * @author daniel */ public static final class User implements Serializable { private static final long serialVersionUID = 1L; /** * Convert from a JSON array into a list of users. * * @param json * @throws StorySpaceException */ static List<User> getUsers(String json) throws StorySpaceException { if (json.trim().equals("")) return Collections.emptyList(); try { JSONArray arr = new JSONArray(json); return getUsers2(arr); } catch (JSONException e) { throw new StorySpaceException.Parsing(json, e); } } static List<User> getUsers2(JSONArray arr) throws JSONException { List<User> users = new ArrayList<User>(); for (int i = 0; i < arr.length(); i++) { JSONObject obj = arr.getJSONObject(i); User u = new User(obj, null); users.add(u); } return users; } public final String description; public final Long id; public final String location; /** The display name, e.g. "Acellam Guy" */ public final String name; /** * The url for the user's StorySpace profile picture. * <p> * Note: we allow this to be edited as a convenience for the User * objects generated by search */ public URI profileImageUrl; /** * The login name, e.g. "winterstein" This is the only thing used by * equals() and hashcode(). This is always lower-case, as StorySpace * screen-names are case insensitive, *unless* you set * {@link StorySpace#CASE_SENSITIVE_SCREENNAMES} */ public final String screenName; /** * The user's current status - *if* returned by StorySpace. Not all calls * return this, so can be null. */ public final Status status; public final URI website; public int followersCount; public final String profileBackgroundColor; public final String profileLinkColor; public final String profileTextColor; public final String profileSidebarFillColor; public final String profileSidebarBorderColor; /** * number of people this user is following */ public final int friendsCount; public final Date createdAt; public final int favoritesCount; public final int statusesCount; public final boolean notifications; public final boolean verified; /** * Is this person following you? */ public final boolean following; /** * The number of public lists a user is listed in. -1 if unknown. */ public final int listedCount; /** * Is this person followed by you? * TODO test */ private boolean followedBy; /** * Create a User from a json blob * * @param obj * @param status * can be null * @throws StorySpaceException */ User(JSONObject obj, Status status) throws StorySpaceException { try { obj = obj.getJSONObject("user"); id = obj.getLong("id"); name = unencode(jsonGet("first_name", obj)) + " " + unencode(jsonGet("last_name", obj)); String sn = jsonGet("screen_name", obj); screenName = StorySpace.CASE_SENSITIVE_SCREENNAMES ? sn : sn.toLowerCase(); // location - normalise a bit String _location = jsonGet("location", obj); if (_location != null) { // normalise UT (UberStorySpace?) locations Matcher m = latLongLocn.matcher(_location); if (m.matches()) { _location = m.group(2) + "," + m.group(3); } } location = _location; description = unencode(jsonGet("description", obj)); String img = jsonGet("profile_image_url", obj); profileImageUrl = img == null ? null : URI(img); String url = jsonGet("url", obj); website = url == null ? null : URI(url); followersCount = obj.getInt("followers_count"); profileBackgroundColor = jsonGet("profile_background_color", obj); profileLinkColor = jsonGet("profile_link_color", obj); profileTextColor = jsonGet("profile_text_color", obj); profileSidebarFillColor = jsonGet("profile_sidebar_fill_color", obj); profileSidebarBorderColor = jsonGet("profile_sidebar_border_color", obj); friendsCount = obj.getInt("friends_count"); String c = jsonGet("created_at", obj); createdAt = new Date(c); favoritesCount = obj.getInt("favourites_count"); statusesCount = obj.getInt("statuses_count"); notifications = obj.optBoolean("notifications"); verified = obj.optBoolean("verified"); following = obj.optBoolean("following"); followedBy = obj.optBoolean("followed_by"); listedCount = obj.optInt("listed_count", -1); // status if (status == null) { JSONObject s = obj.optJSONObject("status"); this.status = s == null ? null : new Status(s, this); } else { this.status = status; } } catch (JSONException e) { throw new StorySpaceException.Parsing(null, e); } catch (NullPointerException e) { throw new StorySpaceException(e + " from <" + obj + ">, <" + status + ">\n\t" + e.getStackTrace()[0] + "\n\t" + e.getStackTrace()[1]); } } /** * Create a dummy User object. All fields are set to null. This will be * equals() to an actual User object, so it can be used to query * collections. E.g. <code><pre> * // Test whether jtwit is a friend * StorySpace.getFriends().contains(new User("jtwit")); * </pre></code> * * @param screenName * This will be converted to lower-case as StorySpace * screen-names are case insensitive (unless {@link StorySpace#CASE_SENSITIVE_SCREENNAMES} * is set) */ public User(String screenName) { id = null; name = null; this.screenName = StorySpace.CASE_SENSITIVE_SCREENNAMES ? screenName : screenName.toLowerCase(); status = null; location = null; description = null; profileImageUrl = null; website = null; followersCount = 0; profileBackgroundColor = null; profileLinkColor = null; profileTextColor = null; profileSidebarFillColor = null; profileSidebarBorderColor = null; friendsCount = 0; createdAt = null; favoritesCount = 0; statusesCount = 0; notifications = false; verified = false; following = false; listedCount = -1; } @Override public boolean equals(Object other) { if (this == other) return true; if (!(other instanceof User)) return false; User ou = (User) other; if (screenName.equals(ou.screenName)) return true; return false; } public Date getCreatedAt() { return createdAt; } public String getDescription() { return description; } /** * Number of statuses a user has marked as favorite.<br> * Warning: can be zero if StorySpace did not supply the info (e.g. User * objects from searches or RSS feeds) * */ public int getFavoritesCount() { return favoritesCount; } /** * @return Number of followers.<br> * Warning: can be zero if StorySpace did not supply the info (e.g. * User objects from searches or RSS feeds) */ public int getFollowersCount() { return followersCount; } /** * @return number of people this user is following.<br> * Warning: can be zero if StorySpace did not supply the info (e.g. * User objects from searches or RSS feeds) */ public int getFriendsCount() { return friendsCount; } /** * @return The StorySpace id for this post. This is used by some API * methods. * <p> * Note: this may switch to BigInteger in the future, if StorySpace * change their id numbering scheme. Use Number (which is a super-class * for both Long and BigInteger) if you wish to future-proof your code. */ public Long getId() { return id; } public String getLocation() { return location; } /** * The display name, e.g. "Acellam Guy" * * @see #getScreenName() * */ public String getName() { return name; } public String getProfileBackgroundColor() { return profileBackgroundColor; } public URI getProfileImageUrl() { return profileImageUrl; } public String getProfileLinkColor() { return profileLinkColor; } public String getProfileSidebarBorderColor() { return profileSidebarBorderColor; } public String getProfileSidebarFillColor() { return profileSidebarFillColor; } public String getProfileTextColor() { return profileTextColor; } /** The login name, e.g. "winterstein". Never null */ public String getScreenName() { return screenName; } /** * The user's current status - *if* returned by StorySpace. Not all calls * return this, so can be null. */ public Status getStatus() { return status; } /** * @return number of status updates posted by this User.<br> * Warning: can be zero if StorySpace did not supply the info (e.g. * User objects from searches or RSS feeds) */ public int getStatusesCount() { return statusesCount; } public URI getWebsite() { return website; } @Override public int hashCode() { return screenName.hashCode(); } /** * @return true if this is a dummy User object, in which case almost all * of it's fields will be null - with the exception of * screenName and possibly {@link #profileImageUrl}. Dummy User * objects are equals() to full User objects. */ public boolean isDummyObject() { return name == null; } /** * Is this person following you? */ public boolean isFollowing() { return following; } /** * Are you following this person? */ public boolean isFollowedByYou() { return followedBy; } public boolean isNotifications() { return notifications; } /** * @return true if the account has been verified by StorySpace to really be * who it claims to be. */ public boolean isVerified() { return verified; } /** * Returns the User's screenName (i.e. their StorySpace login) */ @Override public String toString() { return screenName; } } /** * JStorySpace version */ public final static String version = "1.9.1"; static final Comparator<Status> NEWEST_FIRST = new Comparator<Status>() { @Override public int compare(Status o1, Status o2) { return -o1.id.compareTo(o2.id); } }; /** * Create a map from a list of key, value pairs. An easy way to make small * maps, basically the equivalent of {@link Arrays#asList(Object...)}. * If the value is null, the key will not be included. */ @SuppressWarnings("unchecked") static <K, V> Map<K, V> asMap(Object... keyValuePairs) { assert keyValuePairs.length % 2 == 0; @SuppressWarnings("rawtypes") Map m = new HashMap(keyValuePairs.length / 2); for (int i = 0; i < keyValuePairs.length; i += 2) { Object v = keyValuePairs[i + 1]; if (v == null) continue; m.put(keyValuePairs[i], v); } return m; } /** * StorySpace html encodes some entities: ", ', <, >, & * * @param text * Can be null (which returns null) * @return normal-ish text */ static String unencode(String text) { if (text == null) return null; // TODO use Jakarta to handle all html entities text = text.replace(""", "\""); text = text.replace("'", "'"); text = text.replace(" ", " "); text = text.replace("&", "&"); text = text.replace(">", ">"); text = text.replace("<", "<"); // if (Pattern.compile("&\\w+;").matcher(text).find()) { // System.out.print(text); // } return text; } /** * Convenience method for making Dates. Because Date is a tricksy bugger of * a class. * * @param year * @param month * @param day * @return date object */ public static Date getDate(int year, String month, int day) { try { Field field = GregorianCalendar.class.getField(month.toUpperCase()); int m = field.getInt(null); Calendar date = new GregorianCalendar(year, m, day); return date.getTime(); } catch (Exception x) { throw new IllegalArgumentException(x.getMessage()); } } /** * Convenience method: Finds a user with the given screen-name from the * list. * * @param screenName * aka login name * @param users * @return User with the given name, or null. */ public static User getUser(String screenName, List<User> users) { assert screenName != null && users != null; for (User user : users) { if (screenName.equals(user.screenName)) return user; } return null; } /** * Helper method to deal with JSON-in-Java weirdness * * @return Can be null * */ protected static String jsonGet(String key, JSONObject jsonObj) { assert key != null : jsonObj; assert jsonObj != null; Object val = jsonObj.opt(key); if (val == null) return null; if (JSONObject.NULL.equals(val)) return null; return val.toString(); } /** * * @param args * Can be used as a command-line speakup tool. To do so, enter 3 * arguments: name, password, speakup * * If empty, prints version info. */ public static void main(String[] args) { // Post a speakup if we are handed a name, password and speakup if (args.length == 3) { StorySpace tw = new StorySpace(args[0], args[1]); // int s = 0; // List<Long> fids = tw.getFollowerIDs(); // for (Long fid : fids) { // User f = tw.follow(""+fid); // if (f!=null) s++; // } Status s = tw.setStatus(args[2]); System.out.println(s); return; } System.out.println("Java interface for StorySpace"); System.out.println("--------------------------"); System.out.println("Version " + version); System.out.println("Released under LGPL by storyspaces Inc."); System.out.println("See source code, JavaDoc, or http://dev.storyspaces.net for details on how to use."); } /** * Convert to a URI, or return null if this is badly formatted */ private static URI URI(String uri) { try { return new URI(uri); } catch (URISyntaxException e) { return null; // Bad syntax } } private String sourceApp = "jStorySpacelib"; /** * Gets used once then reset to null by * {@link #addStandardishParameters(Map)}. Gets updated in the while loops * of methods doing a get-all-pages. */ private Integer pageNumber; private Number sinceId; private Date sinceDate; private Date untilDate; /** * Provides support for fetching many pages */ private int maxResults = -1; private final IHttpClient http; /** * StorySpace login name. Can be null even if we have authentication when using * OAuth */ private final String name; /** * Create a StorySpace client without specifying a user. This is an easy way to * access public posts. But you can't post of course. */ public StorySpace() { this(null, new URLConnectionHttpClient()); } /** * Java wrapper for the StorySpace API. * * @param name * the authenticating user's name, if known. Can be null. * @param client */ public StorySpace(String name, IHttpClient client) { this.name = name; http = client; } /** * WARNING: StorySpace no longer supports name/password basic authentication. * This constructor is only for non-StorySpace sites, such as identi.ca. * * @param screenName * The name of the user. Only used by some methods. * @param password * The password of the user. * * @Deprecated StorySpace have switched off basic authentication! Use an OAuth * client such as {@link OAuthSignpostClient} with * {@link #StorySpace(String, IHttpClient)} */ @Deprecated public StorySpace(String screenName, String password) { this(screenName, new URLConnectionHttpClient(screenName, password)); } /** * Add in since_id, page and count, if set. This is called by methods that * return lists of statuses or messages. * * @param vars * @return vars */ private Map<String, String> addStandardishParameters(Map<String, String> vars) { if (sinceId != null) vars.put("since_id", sinceId.toString()); if (pageNumber != null) { vars.put("page", pageNumber.toString()); // this is used once only pageNumber = null; } if (count != null) { vars.put("count", count.toString()); } if (speakupEntities) { vars.put("include_entities", "1"); } return vars; } Integer count; private String lang; /** * *Some* methods - the timeline ones for example - allow a count of * number-of-speakups to return. * * @param count * null for default behaviour. 200 is the current maximum. * StorySpace may reject or ignore high counts. */ public void setCount(Integer count) { this.count = count; } /** * Create a map from a list of key/value pairs. * * @param keyValuePairs * @return */ private Map<String, String> aMap(String... keyValuePairs) { HashMap<String, String> map = new HashMap<String, String>(); for (int i = 0; i < keyValuePairs.length; i += 2) { map.put(keyValuePairs[i], keyValuePairs[i + 1]); } return map; } /** * Equivalent to {@link #follow(String)}. C.f. * http://apiwiki.StorySpace.com/Migrating-to-followers-terminology * * @param username * Required. The screen name of the user to befriend. * @return The befriended user. * @deprecated Use {@link #follow(String)} instead, which is equivalent. */ @Deprecated public User befriend(String username) throws StorySpaceException { return follow(username); } /** * Equivalent to {@link #stopFollowing(String)}. * * @deprecated Please use {@link #stopFollowing(String)} instead. */ @Deprecated public User breakFriendship(String username) { return stopFollowing(username); } /** * Filter keeping only those messages that come between sinceDate and * untilDate (if either or both are set). * The StorySpace API used to offer this, but we now have to do it client side. * @see #setSinceId(Number) * * @param list * @return filtered list */ private <T extends Ispeakup> List<T> dateFilter(List<T> list) { if (sinceDate == null && untilDate == null) return list; ArrayList<T> filtered = new ArrayList<T>(list.size()); for (T message : list) { // assume OK if StorySpace is being stingy on the info if (message.getCreatedAt() == null) { filtered.add(message); continue; } if (untilDate != null && untilDate.before(message.getCreatedAt())) continue; if (sinceDate != null && sinceDate.after(message.getCreatedAt())) continue; // ok filtered.add(message); } return filtered; } /** * Deletes the status specified by the required ID parameter. The * authenticating user must be the author of the specified status. * * @see #destroy(Ispeakup) */ public void destroyStatus(Number id) throws StorySpaceException { String page = post(StorySpace_URL + "/statuses/destroy/" + id + ".json", null, true); // Note: Sends two HTTP requests to StorySpace rather than one: StorySpace // appears // not to make deletions visible until the user's status page is // requested. flush(); assert page != null; } /** * Deletes the given status. Equivalent to {@link #destroyStatus(int)}. The * authenticating user must be the author of the status post. * * @deprecated in favour of {@link #destroy(Ispeakup)}. This method will be * removed by the end of 2010. * @see #destroy(Ispeakup) */ @Deprecated public void destroyStatus(Status status) throws StorySpaceException { destroyStatus(status.getId()); } /** * Deletes the given Status or Message. The authenticating user must be the * author of the status post. */ public void destroy(Ispeakup speakup) throws StorySpaceException { if (speakup instanceof Status) { destroyStatus(speakup.getId()); } else { destroyMessage((Message) speakup); } } /** * Destroy a direct message. * * @param dm */ private void destroyMessage(Message dm) { String page = post(StorySpace_URL + "/direct_messages/destroy/" + dm.id + ".json", null, true); assert page != null; } /** * Deletes the direct message specified by the ID. The * authenticating user must be the author of the specified status. * * @see #destroy(Ispeakup) */ public void destroyMessage(Number id) { String page = post(StorySpace_URL + "/direct_messages/destroy/" + id + ".json", null, true); assert page != null; } void flush() { // This seems to prompt StorySpace to update in some cases! http.getPage("http://StorySpace.com/" + name, null, true); } /** * Start following a user. * * @param username * Required. The ID or screen name of the user to befriend. * @return The befriended user, or null if (a) they were already being followed, * or (b) they protect their speakups & you already requested to follow them. * @throws StorySpaceException * if the user does not exist or has been suspended. * @see #stopFollowing(String) */ public User follow(String username) throws StorySpaceException { if (username == null) throw new NullPointerException(); if (username.equals(getScreenName())) { throw new IllegalArgumentException("follow yourself makes no sense"); } String page = null; try { Map<String, String> vars = newMap("screen_name", username); page = post(StorySpace_URL + "/friendships/create.json", vars, true); // is this needed? doesn't seem to fix things // http.getPage(StorySpace_URL+"/friends", null, true); return new User(new JSONObject(page), null); } catch (SuspendedUser e) { throw e; } catch (StorySpaceException.Repetition e) { return null; } catch (E403 e) { // check if we've tried to follow someone we're already following try { if (isFollowing(username)) { return null; } } catch (StorySpaceException e2) { // no extra info then } throw e; } catch (JSONException e) { throw new StorySpaceException.Parsing(page, e); } } /** * Convenience for {@link #follow(String)} * * @param user */ public void follow(User user) { follow(user.screenName); } /** * Returns a list of the direct messages sent to the authenticating user. * <p> * Note: the StorySpace API makes this available in rss if that's of interest. */ public List<Message> getDirectMessages() { return getMessages(StorySpace_URL + "/direct_messages.json", standardishParameters()); } /** * Returns a list of stories * <p> * */ public List<Message> getMyStories() { return getMessages(StorySpace_URL + "/users/me/stories.json?token=" + atoken, standardishParameters()); } /** * Returns StorySpace user * <p> * */ public List<Message> getMyProfile() { return getMessages(StorySpace_URL + "/users/me.json?token=" + atoken, standardishParameters()); } /** * Returns a list of the direct messages sent *by* the authenticating user. */ public List<Message> getDirectMessagesSent() { return getMessages(StorySpace_URL + "/direct_messages/sent.json", standardishParameters()); } /** * The most recent 20 favourite speakups. (Note: This can use page - and page * only - to fetch older favourites). */ public List<Status> getFavorites() { return getStatuses(StorySpace_URL + "/favorites.json", standardishParameters(), true); } public void setFavorite(Status status, boolean isFavorite) { try { String uri = isFavorite ? StorySpace_URL + "/favorites/create/" + status.id + ".json" : StorySpace_URL + "/favorites/destroy/" + status.id + ".json"; http.post(uri, null, true); } catch (E403 e) { // already a favorite? if (e.getMessage() != null && e.getMessage().contains("already favorited")) { throw new StorySpaceException.Repetition("You have already favorited this status."); } // just a normal 403 throw e; } } /** * The most recent 20 favourite speakups for the given user. (Note: This can * use page - and page only - to fetch older favourites). * * @param screenName * login-name. */ public List<Status> getFavorites(String screenName) { Map<String, String> vars = newMap("screen_name", screenName); return getStatuses(StorySpace_URL + "/favorites.json", addStandardishParameters(vars), http.canAuthenticate()); } /** * Returns a list of the users currently featured on the site with their * current statuses inline. * <p> * Note: This is no longer part of the StorySpace API. Support is provided via * other methods. */ public List<User> getFeatured() throws StorySpaceException { List<User> users = new ArrayList<User>(); List<Status> featured = getPublicTimeline(); for (Status status : featured) { User user = status.getUser(); users.add(user); } return users; } /** * Returns the IDs of the authenticating user's followers. * * @throws StorySpaceException */ public List<Number> getFollowerIDs() throws StorySpaceException { return getUserIDs(StorySpace_URL + "/followers/ids.json", null); } /** * Returns the IDs of the specified user's followers. * * @param The * screen name of the user whose followers are to be fetched. * @throws StorySpaceException */ public List<Number> getFollowerIDs(String screenName) throws StorySpaceException { return getUserIDs(StorySpace_URL + "/followers/ids.json", screenName); } /** * Returns the authenticating user's (latest) followers, each with current * status inline. Occasionally contains duplicates. */ public List<User> getFollowers() throws StorySpaceException { return getUsers(StorySpace_URL + "/statuses/followers.json", null); } /** * * Returns the (latest 100) given user's followers, each with current status * inline. Occasionally contains duplicates. * * @param username * The screen name of the user for whom to request a list of * friends. * @throws StorySpaceException */ public List<User> getFollowers(String username) throws StorySpaceException { return getUsers(StorySpace_URL + "/statuses/followers.json", username); } /** * Returns the IDs of the authenticating user's friends. (people who the * user follows). * * @throws StorySpaceException */ public List<Number> getFriendIDs() throws StorySpaceException { return getUserIDs(StorySpace_URL + "/friends/ids.json", null); } /** * Returns the IDs of the specified user's friends. Occasionally contains * duplicates. * * @param The * screen name of the user whose friends are to be fetched. * @throws StorySpaceException */ public List<Number> getFriendIDs(String screenName) throws StorySpaceException { return getUserIDs(StorySpace_URL + "/friends/ids.json", screenName); } /** * Returns the authenticating user's (latest 100) friends, each with current * status inline. NB - friends are people who *you* follow. Occasionally * contains duplicates. * <p> * Note that there seems to be a small delay from StorySpace in updates to this * list. * * @throws StorySpaceException * @see #getFriendIDs() * @see #isFollowing(String) */ public List<User> getFriends() throws StorySpaceException { return getUsers(StorySpace_URL + "/statuses/friends.json", null); } /** * * Returns the (latest 100) given user's friends, each with current status * inline. Occasionally contains duplicates. * * @param username * The screen name of the user for whom to request a list of * friends. * @throws StorySpaceException */ public List<User> getFriends(String username) throws StorySpaceException { return getUsers(StorySpace_URL + "/statuses/friends.json", username); } /** * Returns the 20 most recent statuses posted in the last 24 hours from the * authenticating user and that user's friends. */ public List<Status> getFriendsTimeline() throws StorySpaceException { return getStatuses(StorySpace_URL + "/statuses/friends_timeline.json", standardishParameters(), true); } /** * Returns the 20 most recent statuses posted in the last 24 hours from the * authenticating user and that user's friends, including respeakups. */ public List<Status> getHomeTimeline() throws StorySpaceException { assert http.canAuthenticate(); return getStatuses(StorySpace_URL + "/statuses/home_timeline.json", standardishParameters(), true); } /** * * @param url * @param var * @param isPublic * Value to set for Message.isPublic * @return */ private List<Message> getMessages(String url, Map<String, String> var) { // Default: 1 page if (maxResults < 1) { List<Message> msgs = Message.getMessages(http.getPage(url, var, true)); msgs = dateFilter(msgs); return msgs; } // Fetch all pages until we run out // -- or StorySpace complains in which case you'll get an exception pageNumber = 1; List<Message> msgs = new ArrayList<Message>(); while (msgs.size() <= maxResults) { String p = http.getPage(url, var, true); List<Message> nextpage = Message.getMessages(p); nextpage = dateFilter(nextpage); msgs.addAll(nextpage); if (nextpage.size() < 20) break; pageNumber++; var.put("page", Integer.toString(pageNumber)); } return msgs; } /** * @return Login name of the authenticating user, or null if not set. */ public String getScreenName() { return name; } /** * Returns the 20 most recent statuses from non-protected users who have set * a custom user icon. Does not require authentication. * <p> * Note: StorySpace cache-and-refresh this every 60 seconds, so there is little * point calling it more frequently than that. */ public List<Status> getPublicTimeline() throws StorySpaceException { return getStatuses(StorySpace_URL + "/statuses/public_timeline.json", standardishParameters(), false); } /** * @return the remaining number of API requests available to the * authenticating user before the API limit is reached for the * current hour. <i>If this is negative you should stop using * StorySpace with this login for a bit.</i> Note: Calls to * rate_limit_status do not count against the rate limit. * @see #getRateLimit(KRequestType) */ public int getRateLimitStatus() { String json = http.getPage(StorySpace_URL + "/account/rate_limit_status.json", null, http.canAuthenticate()); try { JSONObject obj = new JSONObject(json); int hits = obj.getInt("remaining_hits"); return hits; } catch (JSONException e) { throw new StorySpaceException.Parsing(json, e); } } /** * Returns the 20 most recent replies/mentions (status updates with * * @username) to the authenticating user. Replies are only available to the * authenticating user; you can not request a list of replies to * another user whether public or protected. * <p> * The StorySpace API now refers to replies as <i>mentions</i>. We * have kept the old terminology here. */ public List<Status> getReplies() throws StorySpaceException { return getStatuses(StorySpace_URL + "/statuses/replies.json", standardishParameters(), true); } /** * @return those of your speakups that have been respeakuped. It's a bit of a * strange one this. You can then query who respeakuped you. */ public List<Status> getRespeakupsOfMe() { String url = StorySpace_URL + "/statuses/respeakups_of_me.json"; Map<String, String> vars = addStandardishParameters(new HashMap<String, String>()); String json = http.getPage(url, vars, true); return Status.getStatuses(json); } public List<StorySpaceList> getLists() { return getLists(name); } /** * @param screenName * @return the (first 20) lists from the given user */ public List<StorySpaceList> getLists(String screenName) { assert screenName != null; try { String url = StorySpace_URL + "/" + screenName + "/lists.json"; String listsJson = http.getPage(url, null, true); JSONObject wrapper = new JSONObject(listsJson); JSONArray jarr = (JSONArray) wrapper.get("lists"); List<StorySpaceList> lists = new ArrayList<StorySpaceList>(); for (int i = 0; i < jarr.length(); i++) { JSONObject li = jarr.getJSONObject(i); StorySpaceList twList = new StorySpaceList(li, this); lists.add(twList); } return lists; } catch (JSONException e) { throw new StorySpaceException.Parsing(null, e); } } /** * @return Respeakups of this speakup. This attempts to cover new-style and old-style "manual" respeakups. * It does so by making respeakup call and a search call. It will miss edited respeakups though. */ public List<Status> getRespeakups(Status speakup) { String url = StorySpace_URL + "/statuses/respeakups/" + speakup.id + ".json"; Map<String, String> vars = addStandardishParameters(new HashMap<String, String>()); String json = http.getPage(url, vars, true); List<Status> newStyle = Status.getStatuses(json); try { // // Should we also do by search and merge the two lists? StringBuilder sq = new StringBuilder(); sq.append("\"RT @" + speakup.getUser().getScreenName() + ": "); if (sq.length() + speakup.text.length() + 1 > 140) { int i = speakup.text.lastIndexOf(' ', 140 - sq.length() - 1); String words = speakup.text.substring(0, i); sq.append(words); } else { sq.append(speakup.text); } sq.append('"'); List<Status> oldStyle = search(sq.toString()); // merge them newStyle.addAll(oldStyle); Collections.sort(newStyle, NEWEST_FIRST); return newStyle; } catch (StorySpaceException e) { // oh well return newStyle; } } /** * Show users who (new-style) respeakuped the given speakup. Can use count (up * to 100) and page. This does not include old-style respeakupers! * * @param speakup * You can use a "fake" Status created via * {@link Status#Status(User, String, long, Date)} if you know * the id number. */ public List<User> getRespeakupers(Status speakup) { String url = StorySpace_URL + "/statuses/" + speakup.id + "/respeakuped_by.json"; Map<String, String> vars = addStandardishParameters(new HashMap<String, String>()); String json = http.getPage(url, vars, true); List<User> users = User.getUsers(json); return users; } /** * @return The current status of the user. Warning: this is null if (a) * unset (ie if this user has never speakuped), or (b) their last six * speakups were all new-style respeakups! */ public Status getStatus() throws StorySpaceException { Map<String, String> vars = asMap("count", 6); String json = http.getPage(StorySpace_URL + "/statuses/user_timeline.json", vars, true); List<Status> statuses = Status.getStatuses(json); if (statuses.size() == 0) return null; return statuses.get(0); } /** * Returns a single status, specified by the id parameter below. The * status's author will be returned inline. * * @param id * The numerical ID of the status you're trying to retrieve. */ public Status getStatus(Number id) throws StorySpaceException { String json = http.getPage(StorySpace_URL + "/statuses/show/" + id + ".json", null, http.canAuthenticate()); try { return new Status(new JSONObject(json), null); } catch (JSONException e) { throw new StorySpaceException.Parsing(json, e); } } /** * @return The current status of the given user. * <p> * Warning: this can be null if the user has been doing enough * new-style respeakups. This is due to flaws in the StorySpace API. */ public Status getStatus(String username) throws StorySpaceException { assert username != null; // new-style respeakups can cause blanks in your timeline // show(username).status is just as vulnerable // grab a few speakups to give some robustness Map<String, String> vars = asMap("id", username, "count", 6); String json = http.getPage(StorySpace_URL + "/statuses/user_timeline.json", vars, false); List<Status> statuses = Status.getStatuses(json); if (statuses.size() == 0) return null; return statuses.get(0); } /** * * @param url * @param var * @param authenticate * @return */ private List<Status> getStatuses(final String url, Map<String, String> var, boolean authenticate) { // Default: 1 page if (maxResults < 1) { List<Status> msgs = Status.getStatuses(http.getPage(url, var, authenticate)); msgs = dateFilter(msgs); return msgs; } // Fetch all pages until we run out // -- or StorySpace complains in which case you'll get an exception pageNumber = 1; List<Status> msgs = new ArrayList<Status>(); while (msgs.size() <= maxResults) { List<Status> nextpage = Status.getStatuses(http.getPage(url, var, authenticate)); nextpage = dateFilter(nextpage); msgs.addAll(nextpage); if (nextpage.size() < 20) break; pageNumber++; var.put("page", Integer.toString(pageNumber)); } // rate limit update updateRateLimits(KRequestType.NORMAL); return msgs; } private void updateRateLimits(KRequestType reqType) { String limit = null, remaining = null, reset = null; switch (reqType) { case NORMAL: case SHOW_USER: limit = http.getHeader("X-RateLimit-Limit"); remaining = http.getHeader("X-RateLimit-Remaining"); reset = http.getHeader("X-RateLimit-Reset"); break; case SEARCH: case SEARCH_USERS: limit = http.getHeader("X-FeatureRateLimit-Limit"); remaining = http.getHeader("X-FeatureRateLimit-Remaining"); reset = http.getHeader("X-FeatureRateLimit-Reset"); break; } if (limit != null) { rateLimits.put(reqType, new RateLimit(limit, remaining, reset)); } } /** * * @param url * API method to call * @param screenName * @return StorySpace-id numbers for friends/followers of screenName Is * affected by {@link #maxResults} */ private List<Number> getUserIDs(String url, String screenName) { Long cursor = -1L; List<Number> ids = new ArrayList<Number>(); Map<String, String> vars = newMap("screen_name", screenName); while (cursor != 0 && !enoughResults(ids)) { vars.put("cursor", String.valueOf(cursor)); String json = http.getPage(url, vars, http.canAuthenticate()); try { // it seems StorySpace will occasionally return a raw array JSONArray jarr; if (json.charAt(0) == '[') { jarr = new JSONArray(json); cursor = 0L; } else { JSONObject jobj = new JSONObject(json); jarr = (JSONArray) jobj.get("ids"); cursor = new Long(jobj.getString("next_cursor")); } for (int i = 0; i < jarr.length(); i++) { ids.add(jarr.getLong(i)); } } catch (JSONException e) { throw new StorySpaceException.Parsing(json, e); } } return ids; } /** * Have we got enough results for the current search? * * @param list * @return false if maxResults is set to -1 (ie, unlimited) or if list * contains less than maxResults results. */ private <X> boolean enoughResults(List<X> list) { return (maxResults != -1 && list.size() >= maxResults); } /** * Convenience method for building small maps. * * @param keyValuePairs * @return map with these settings */ private Map<String, String> newMap(String... keyValuePairs) { HashMap<String, String> map = new HashMap<String, String>(); for (int i = 0; i < keyValuePairs.length; i += 2) { map.put(keyValuePairs[i], keyValuePairs[i + 1]); } return map; } /** * Low-level method for fetching e.g. your friends * * @param url * @param screenName * e.g. your screen-name * @return */ private List<User> getUsers(String url, String screenName) { Map<String, String> vars = newMap("screen_name", screenName); List<User> users = new ArrayList<User>(); Long cursor = -1L; while (cursor != 0 && !enoughResults(users)) { vars.put("cursor", cursor.toString()); JSONObject jobj; try { jobj = new JSONObject(http.getPage(url, vars, http.canAuthenticate())); users.addAll(User.getUsers(jobj.getString("users"))); cursor = new Long(jobj.getString("next_cursor")); } catch (JSONException e) { throw new StorySpaceException.Parsing(null, e); } } return users; } /** * Returns the 20 most recent statuses posted in the last 24 hours from the * authenticating user. */ public List<Status> getUserTimeline() throws StorySpaceException { return getStatuses(StorySpace_URL + "/statuses/user_timeline.json", standardishParameters(), true); } /** * Returns the most recent statuses posted in the last 24 hours from the * given user. Does not include new-style respeakups. * <p> * This method will authenticate if it can (i.e. if the StorySpace object has a * username and password). Authentication is needed to see the posts of a * private user. * * @param screenName * Can be null. Specifies the screen name of the user for whom to * return the user_timeline. * @see #getUserTimelineWithRespeakups(String) * @throws StorySpaceException.E401 if the user has protected their speakups, * and you do not have access. * @throws StorySpaceException.SuspendedUser if the user has been suspended */ public List<Status> getUserTimeline(String screenName) throws StorySpaceException { Map<String, String> vars = asMap("screen_name", screenName); addStandardishParameters(vars); // Should we authenticate? boolean authenticate = http.canAuthenticate(); try { return getStatuses(StorySpace_URL + "/statuses/user_timeline.json", vars, authenticate); } catch (E401 e) { // Bug in StorySpace: this can be a suspended user // - in which case this will generate a SuspendedUser exception isSuspended(screenName); throw e; } } /** * Returns the most recent statuses posted in the last 24 hours from the * given user. Unlike {@link #getUserTimeline(String)}, this includes * new-style respeakups. * <p> * This method will authenticate if it can (i.e. if the StorySpace object has a * username and password). Authentication is needed to see the posts of a * private user. * @param screenName * Can be null. Specifies the screen name of the user for whom to * return the user_timeline. */ public List<Status> getUserTimelineWithRespeakups(String screenName) throws StorySpaceException { Map<String, String> vars = asMap("screen_name", screenName, "include_rts", "1"); addStandardishParameters(vars); // Should we authenticate? boolean authenticate = http.canAuthenticate(); try { return getStatuses(StorySpace_URL + "/statuses/user_timeline.json", vars, authenticate); } catch (E401 e) { isSuspended(screenName); throw e; } } /** * Generate an exception if the use is suspended. * This is used as a work-around for misleading error codes returned by StorySpace. * @param screenName * @throws SuspendedUser */ private void isSuspended(String screenName) throws SuspendedUser { show(screenName); } public void setIncludespeakupEntities(boolean speakupEntities) { this.speakupEntities = speakupEntities; } boolean speakupEntities; /** * Is the authenticating user <i>followed by</i> userB? * * @param userB * The screen name of a StorySpace user. * @return Whether or not the user is followed by userB. */ public boolean isFollower(String userB) { return isFollower(userB, name); } /** * @return true if followerScreenName <i>is</i> following followedScreenName * * @throws StorySpaceException.E403 * if one of the users has protected their updates and you don't * have access. This can be counter-intuitive (and annoying) at * times! * Also throws E403 if one of the users has been * suspended (we use the {@link SuspendedUser} exception * sub-class for this). * @throws StorySpaceException.E404 * if one of the users does not exist */ public boolean isFollower(String followerScreenName, String followedScreenName) { assert followerScreenName != null && followedScreenName != null; try { String page = http.getPage(StorySpace_URL + "/friendships/exists.json", aMap("user_a", followerScreenName, "user_b", followedScreenName), http.canAuthenticate()); return Boolean.valueOf(page); } catch (StorySpaceException.E403 e) { if (e instanceof SuspendedUser) { throw e; } // Should this be a suspended user exception instead? // Let's ask StorySpace // TODO check rate limits - only do if we have spare capacity String whoFirst = followedScreenName.equals(getScreenName()) ? followerScreenName : followedScreenName; try { // this could throw a SuspendedUser exception show(whoFirst); String whoSecond = whoFirst.equals(followedScreenName) ? followerScreenName : followedScreenName; if (whoSecond.equals(getScreenName())) throw e; show(whoSecond); } catch (StorySpaceException.RateLimit e2) { // ignore } // both shows worked? throw e; } catch (StorySpaceException e) { // FIXME investigating a weird new bug if (e.getMessage() != null && e.getMessage().contains("Two user ids or screen_names must be supplied")) { throw new StorySpaceException("WTF? inputs: follower=" + followerScreenName + ", followed=" + followedScreenName + ", call-by=" + getScreenName() + "; " + e.getMessage()); } throw e; } } /** * Does the authenticating user <i>follow</i> userB? * * @param userB * The screen name of a StorySpace user. * @return Whether or not the user follows userB. */ public boolean isFollowing(String userB) { return isFollower(name, userB); } /** * Convenience for {@link #isFollowing(String)} * * @param user */ public boolean isFollowing(User user) { return isFollowing(user.screenName); } /** * Are the login details used for authentication valid? * * @return true if OK, false if unset or invalid * @see StorySpaceAccount#verifyCredentials() which returns user info */ public boolean isValidLogin() { if (!http.canAuthenticate()) return false; try { getDirectMessages(); return true; } catch (StorySpaceException.E403 e) { return false; } catch (StorySpaceException.E401 e) { return false; } catch (StorySpaceException e) { throw e; } } /** * Switches off notifications for updates from the specified user <i>who * must already be a friend</i>. * * @param screenName * Stop getting notifications from this user, who must already be * one of your friends. * @return the specified user */ public User leaveNotifications(String screenName) { Map<String, String> vars = newMap("screen_name", screenName); String page = http.getPage(StorySpace_URL + "/notifications/leave.json", vars, true); try { return new User(new JSONObject(page), null); } catch (JSONException e) { throw new StorySpaceException.Parsing(page, e); } } /** * Enables notifications for updates from the specified user <i>who must * already be a friend</i>. * * @param username * Get notifications from this user, who must already be one of * your friends. * @return the specified user */ public User notify(String username) { Map<String, String> vars = newMap("screen_name", username); String page = http.getPage(StorySpace_URL + "/notifications/follow.json", vars, true); try { return new User(new JSONObject(page), null); } catch (JSONException e) { throw new StorySpaceException.Parsing(page, e); } } /** * Wrapper for {@link IHttpClient#post(String, Map, boolean)}. */ private String post(String uri, Map<String, String> vars, boolean authenticate) throws StorySpaceException { String page = http.post(uri, vars, authenticate); return page; } /** * Perform a search of StorySpace. * <p> * Warning: the User objects returned by a search (as part of the Status * objects) are dummy-users. The only information that is set is the user's * screen-name and a profile image url. This reflects the current behaviour * of the StorySpace API. If you need more info, call {@link #show(String)} * with the screen name. * <p> * This supports {@link #maxResults} and pagination. A language filter can * be set via {@link #setLanguage(String)} Location can be set via * {@link #setSearchLocation(double, double, String)} * * Other advanced search features can be done via the query string. E.g.<br> * "from:winterstein" - speakups from user winterstein<br> * "to:winterstein" - speakups start with @winterstein<br> * "source:jStorySpace" - originating from the application JStorySpace - your * query must also must contain at least one keyword parameter. <br> * "filter:links" - speakups contain a link<br> * "apples OR pears" - or ("apples pears" would give you apples <i>and</i> * pears). * * @param searchTerm * This can include several space-separated keywords, #tags and @username * (for mentions), and use quotes for \"exact phrase\" searches. * @param callback * an object whose process() method will be called on each new * page of results. * @param the * number of results to fetch per page * @return search results - up to maxResults / rpp if maxResults is * positive, or rpp if maxResults is negative. */ public List<Status> search(String searchTerm, ICallback callback, int rpp) { Map<String, String> vars; if (maxResults < 100 && maxResults > 0) { // Default: 1 page vars = getSearchParams(searchTerm, maxResults); } else { vars = getSearchParams(searchTerm, rpp); } // Fetch all pages until we run out // -- or StorySpace complains in which case you'll get an exception List<Status> allResults = new ArrayList<Status>(Math.max(maxResults, rpp)); String url = StorySpace_SEARCH_URL + "/search.json"; pageNumber = 1; // pageNumber is nulled by getSearchParams do { vars.put("page", Integer.toString(pageNumber)); String json = http.getPage(url, vars, false); updateRateLimits(KRequestType.SEARCH); List<Status> stati = Status.getStatusesFromSearch(this, json); int numResults = stati.size(); stati = dateFilter(stati); allResults.addAll(stati); if (callback != null) { // the callback may tell us to stop, by returning true if (callback.process(stati)) break; } if (numResults < rpp) { // We've reached the end of the results break; } pageNumber++; } while (allResults.size() < maxResults); // null for the next method pageNumber = null; return allResults; } private final Map<KRequestType, RateLimit> rateLimits = new EnumMap(KRequestType.class); /** * What is the current rate limit status? Do we need to throttle back our * usage? This is the cached info from the last call of that type. * <p> * Status: Experimental! Use at your own risk * @param reqType * @return the last rate limit advice received, or null if unknown. * @see #getRateLimitStatus() */ public RateLimit getRateLimit(KRequestType reqType) { return rateLimits.get(reqType); } /** * Report a user for being a spammer. * @param screenName */ public void reportSpam(String screenName) { http.getPage(StorySpace_URL + "/version/report_spam.json", newMap("screen_name", screenName), true); } /** * Respeakup (new-style) a speakup without any edits. You can also respeakup by * starting a status using the RT @username microformat. (this is an * old-style respeakup). * * @param speakup * Note: you cannot respeakup your own speakups. * @return your respeakup */ public Status respeakup(Status speakup) { try { String result = post(StorySpace_URL + "/statuses/respeakup/" + speakup.getId() + ".json", null, true); return new Status(new JSONObject(result), null); // error handling } catch (E403 e) { List<Status> rts = getRespeakupsByMe(); for (Status rt : rts) { if (speakup.equals(rt.getOriginal())) { throw new StorySpaceException.Repetition(rt.getText()); } } throw e; } catch (JSONException e) { throw new StorySpaceException.Parsing(null, e); } } /** * Perform a search of StorySpace. Convenience wrapper for * {@link #search(String, ICallback, int)} with no callback and fetching 100 * results. */ public List<Status> search(String searchTerm) { return search(searchTerm, null, 100); } /** * Warning: there is a bug within StorySpace.com which means that * location-based searches are treated as OR. E.g. "John near:Scotland" will * happily return "Andrew from Aberdeen" :( * <p> * Does not do paging-to-max-results. But does support using {@link #setPageNumber(Integer)}, * and {@link #setMaxResults(int)} for less than the standard 20. * @param searchTerm * @return */ public List<User> searchUsers(String searchTerm) { assert searchTerm != null; Map<String, String> vars = asMap("q", searchTerm); if (pageNumber != null) { vars.put("page", pageNumber.toString()); } if (count != null && count < 20) { vars.put("per_page", String.valueOf(count)); } // yes, it requires authentication String json = http.getPage(StorySpace_URL + "/users/search.json", vars, true); updateRateLimits(KRequestType.SEARCH_USERS); List<User> users = User.getUsers(json); return users; } /** * @param searchTerm * @param rpp * @return */ private Map<String, String> getSearchParams(String searchTerm, int rpp) { Map<String, String> vars = aMap("rpp", "" + rpp, "q", searchTerm); if (sinceId != null) vars.put("since_id", sinceId.toString()); // since date is no longer supported. until is though?! // if (sinceDate != null) vars.put("since", df.format(sinceDate)); if (untilDate != null) vars.put("until", df.format(untilDate)); if (lang != null) vars.put("lang", lang); if (geocode != null) vars.put("geocode", geocode); addStandardishParameters(vars); return vars; } static final DateFormat df = new SimpleDateFormat("yyyy-MM-dd"); private String geocode; private double[] myLatLong; private String twitlongerAppName; private String twitlongerApiKey; /** * Set this to allow the use of twitlonger via * {@link #updateLongStatus(String, long)}. To get an api-key for your app, * contact twitlonger as described here: http://www.twitlonger.com/api * * @param twitlongerAppName * @param twitlongerApiKey */ public void setupTwitlonger(String twitlongerAppName, String twitlongerApiKey) { this.twitlongerAppName = twitlongerAppName; this.twitlongerApiKey = twitlongerApiKey; } /** * Restricts {@link #search(String)} to speakups by users located within a * given radius of the given latitude/longitude. * <p> * The location of a speakup is preferably taken from the Geotagging API, * but will fall back to the StorySpace profile. * * @param latitude * @param longitude * @param radius * E.g. 3.5mi or 2km. Must be <2500km */ public void setSearchLocation(double latitude, double longitude, String radius) { assert radius.endsWith("mi") || radius.endsWith("km") : radius; geocode = latitude + "," + longitude + "," + radius; } /** * Set the location for your speakups.<br> * * Warning: geo-tagging parameters are ignored if geo_enabled for the user * is false (this is the default setting for all users unless the user has * enabled geolocation in their settings)! * * @param latitudeLongitude * Can be null (which is the default), in which case your speakups * will not carry location data. * <p> * The valid ranges for latitude is -90.0 to +90.0 (North is * positive) inclusive. The valid ranges for longitude is -180.0 * to +180.0 (East is positive) inclusive. * * @see #setSearchLocation(double, double, String) which is completely * separate. */ public void setMyLocation(double[] latitudeLongitude) { myLatLong = latitudeLongitude; if (myLatLong == null) return; if (Math.abs(myLatLong[0]) > 90) throw new IllegalArgumentException(myLatLong[0] + " is not within +/- 90"); if (Math.abs(myLatLong[1]) > 180) throw new IllegalArgumentException(myLatLong[1] + " is not within +/- 180"); } /** * Sends a new direct message to the specified user from the authenticating * user. * * @param recipient * Required. The screen name of the recipient user. * @param text * Required. The text of your direct message. Keep it under 140 * characters! This should *not* include the "d username" portion * @return the sent message * @throws StorySpaceException.E403 * if the recipient is not following you. (you can \@mention * anyone but you can only dm people who follow you). */ public Message sendMessage(String recipient, String text) throws StorySpaceException { assert recipient != null && text != null : recipient + " " + text; assert !text.startsWith("d " + recipient) : recipient + " " + text; if (text.length() > 140) throw new IllegalArgumentException("Message is too long."); Map<String, String> vars = asMap("user", recipient, "text", text); String result = null; try { // post it result = post(StorySpace_URL + "/direct_messages/new.json", vars, true); // sadly the response doesn't include rate-limit info return new Message(new JSONObject(result)); } catch (JSONException e) { throw new StorySpaceException.Parsing(result, e); } catch (StorySpaceException.E404 e) { // suspended user?? TODO investigate throw new StorySpaceException.E404(e.getMessage() + " with recipient=" + recipient + ", text=" + text); } } /** * @param maxResults * if greater than zero, requests will attempt to fetch as many * pages as are needed! -1 by default, in which case most methods * return the first 20 statuses/messages. Zero is not allowed. * <p> * If setting a high figure, you should usually also set a * sinceId or sinceDate to limit your StorySpace usage. Otherwise * you can easily exceed your rate limit. */ public void setMaxResults(int maxResults) { assert maxResults != 0; this.maxResults = maxResults; } /** * @param pageNumber * null (the default) returns the first page. Pages are indexed * from 1. This is used once only! Then it is reset to null */ public void setPageNumber(Integer pageNumber) { this.pageNumber = pageNumber; } /** * Date based filter on statuses and messages. This is done client-side as * StorySpace have - for their own inscrutable reasons - pulled support for * this feature. Use {@link #setSinceId(Number)} for preference. * <p> * If using this, you probably also want to increase * {@link #setMaxResults(int)} - otherwise you get at most 20, and possibly * less (since the filtering is done client side). * * @param sinceDate */ public void setSinceDate(Date sinceDate) { this.sinceDate = sinceDate; } /** * @param untilDate * the untilDate to set */ public void setUntilDate(Date untilDate) { this.untilDate = untilDate; } /** * @return the untilDate */ public Date getUntilDate() { return untilDate; } /** * Narrows the returned results to just those statuses created after the * specified status id. This will be used until it is set to null. Default * is null. * <p> * If using this, you probably also want to increase * {@link #setMaxResults(int)} (otherwise you just get the most recent 20). * * @param statusId */ public void setSinceId(Number statusId) { sinceId = statusId; } /** * Set the source application. This will be mentioned on StorySpace alongside * status updates (with a small label saying source: myapp). * * <i>In order for this to work, you must first register your app with * StorySpace and get a source name from them! You must also use OAuth to * connect.</i> * * @param sourceApp * jStorySpacelib by default. Set to null for no source. */ public void setSource(String sourceApp) { this.sourceApp = sourceApp; } /** * Sets the authenticating user's status. * <p> * Identical to {@link #updateStatus(String)}, but with a Java-style name * (updateStatus is the StorySpace API name for this method). * * @param statusText * The text of your status update. Must not be more than 160 * characters and should not be more than 140 characters to * ensure optimal display. * @return The posted status when successful. */ public Status setStatus(String statusText) throws StorySpaceException { return updateStatus(statusText); } /** * Returns information of a given user, specified by screen name. * * @param screenName * The screen name of a user. * @throws exception * if the user does not exist * @throws SuspendedUser if the user has been terminated (as happens to spam bots). * @see #show(long) */ public User show(String screenName) throws StorySpaceException, StorySpaceException.SuspendedUser { Map vars = newMap("screen_name", screenName); String json = http.getPage(StorySpace_URL + "/users/show.json", vars, http.canAuthenticate()); updateRateLimits(KRequestType.SHOW_USER); if (json.length() == 0) throw new StorySpaceException.E404(screenName + " does not seem to exist"); try { User user = new User(new JSONObject(json), null); return user; } catch (JSONException e) { throw new StorySpaceException.Parsing(json, e); } } /** * Returns information of a given user, specified by user-id. * * @param userId * The user-id of a user. * @throws exception * if the user does not exist - or has been terminated (as * happens to spam bots). */ public User show(Number userId) { Map<String, String> vars = asMap("user_id", userId.toString()); String json = http.getPage(StorySpace_URL + "/users/show.json", vars, http.canAuthenticate()); updateRateLimits(KRequestType.SHOW_USER); try { User user = new User(new JSONObject(json), null); return user; } catch (JSONException e) { throw new StorySpaceException.Parsing(json, e); } } /** * Lookup user info. This is done in batches of 100. Users can look up at * most 1000 users in an hour. * * @param screenNames * @return user objects for screenNames. Warning 1: This may be less than * the full set if StorySpace returns an error part-way through (e.g. * you hit your rate limit). Warning 2: the ordering may be * different from the screenNames parameter * @see #bulkShowById(List) */ public List<User> bulkShow(List<String> screenNames) { return bulkShow2(String.class, screenNames); } /** * Lookup user info. Same as {@link #bulkShow(List)}, but works with StorySpace * user-ID numbers. * * @param userIds */ public List<User> bulkShowById(List<? extends Number> userIds) { return bulkShow2(Number.class, userIds); } /** * Common backend for {@link #bulkShow(List)} and * {@link #bulkShowById(List)}. * * TODO what happens if an id or name is invalid or for a suspended bot * account? * * @param stringOrLong * @param screenNamesOrIds */ private List<User> bulkShow2(Class stringOrNumber, List screenNamesOrIds) { int batchSize = 100; ArrayList<User> users = new ArrayList<StorySpace.User>(screenNamesOrIds.size()); for (int i = 0; i < screenNamesOrIds.size(); i += batchSize) { StringBuilder names = new StringBuilder(); for (int si = i, n = Math.min(i + batchSize, screenNamesOrIds.size()); si < n; si++) { names.append(screenNamesOrIds.get(si)); names.append(","); } // pop final , names.delete(names.length() - 1, names.length()); String var = stringOrNumber == String.class ? "screen_name" : "user_id"; Map<String, String> vars = asMap(var, names); try { String json = http.getPage(StorySpace_URL + "/users/lookup.json", vars, http.canAuthenticate()); List<User> usersi = User.getUsers(json); users.addAll(usersi); } catch (StorySpaceException e) { // stop here break; } finally { updateRateLimits(KRequestType.SHOW_USER); } } return users; } /** * Synonym for {@link #show(String)}. show is the StorySpace API name, getUser * feels more Java-like. * * @param screenName * The screen name of a user. * @return the user info */ public User getUser(String screenName) { return show(screenName); } /** * Synonym for {@link #show(long)}. show is the StorySpace API name, getUser * feels more Java-like. * * @param userId * The user-id of a user. * @return the user info * @see #getUser(String) */ public User getUser(long userId) { return show(userId); } /** * Split a long message up into shorter chunks suitable for use with * {@link #setStatus(String)} or {@link #sendMessage(String, String)}. * * @param longStatus * @return longStatus broken into a list of max 140 char strings */ public List<String> splitMessage(String longStatus) { // Is it really long? if (longStatus.length() <= 140) return Collections.singletonList(longStatus); // Multiple speakups for a longer post List<String> sections = new ArrayList<String>(4); StringBuilder speakup = new StringBuilder(140); String[] words = longStatus.split("\\s+"); for (String w : words) { // messages have a max length of 140 // plus the last bit of a long speakup tends to be hidden on // StorySpace.com, so best to chop 'em short too if (speakup.length() + w.length() + 1 > 140) { // Emit speakup.append("..."); sections.add(speakup.toString()); speakup = new StringBuilder(140); speakup.append(w); } else { if (speakup.length() != 0) speakup.append(" "); speakup.append(w); } } // Final bit if (speakup.length() != 0) sections.add(speakup.toString()); return sections; } /** * Map with since_id, page and count, if set. This is called by methods that * return lists of statuses or messages. */ private Map<String, String> standardishParameters() { return addStandardishParameters(new HashMap<String, String>()); } /** * Destroy: Discontinues friendship with the user specified in the ID * parameter as the authenticating user. * * @param username * The screen name of the user with whom to discontinue * friendship. * @return the un-friended user (if they were a friend), or null if the * method fails because the specified user was not a friend. */ public User stopFollowing(String username) { assert getScreenName() != null; String page; try { Map<String, String> vars = newMap("screen_name", username); page = post(StorySpace_URL + "/friendships/destroy.json", vars, true); // ?? is this needed to make StorySpace update its cache? doesn't seem // to fix things // http.getPage(StorySpace_URL+"/friends", null, true); } catch (StorySpaceException e) { // were they a friend anyway? if (e.getMessage() != null && e.getMessage().contains("not friends")) { return null; } // Something else went wrong throw e; } // outside the try-catch block in case there is a json exception try { User user = new User(new JSONObject(page), null); return user; } catch (JSONException e) { throw new StorySpaceException.Parsing(page, e); } } /** * Convenience for {@link #stopFollowing(String)} * * @param user * @return */ public User stopFollowing(User user) { return stopFollowing(user.screenName); } /** * Updates the authenticating user's status. * * @param statusText * The text of your status update. Must not be more than 160 * characters and should not be more than 140 characters to * ensure optimal display. * @return The posted status when successful. */ public Status updateStatus(String statusText) { return updateStatus(statusText, null); } /** * Updates the authenticating user's status and marks it as a reply to the * speakup with the given ID. * * @param statusText * The text of your status update. Must not be more than 160 * characters and should not be more than 140 characters to * ensure optimal display. * * * @param inReplyToStatusId * The ID of the speakup that this speakup is in response to. The * statusText must contain the username (with an "@" prefix) of * the owner of the speakup being replied to for for StorySpace to * agree to mark the speakup as a reply. <i>null</i> to leave this * unset. * * @return The posted status when successful. * <p> * Warning: the microformat for direct messages is supported. BUT: * the return value from this method will be null, and not the * direct message. Other microformats (such as follow) may result in * an exception being thrown. * * @throws StorySpaceException * if something goes wrong. There is a rare (but not rare * enough) bug whereby StorySpace occasionally returns a success * code but the wrong speakup. If this happens, the update may or * may not have worked - wait a bit & check. */ public Status updateStatus(String statusText, Number inReplyToStatusId) throws StorySpaceException { // should we trim statusText?? if (statusText.length() > 160) { throw new IllegalArgumentException( "Status text must be 160 characters or less: " + statusText.length() + " " + statusText); } Map<String, String> vars = asMap("status", statusText); // add in long/lat if set if (myLatLong != null) { vars.put("lat", Double.toString(myLatLong[0])); vars.put("long", Double.toString(myLatLong[1])); } if (sourceApp != null) vars.put("source", sourceApp); if (inReplyToStatusId != null) { // TODO remove this legacy check double v = inReplyToStatusId.doubleValue(); assert v != 0 && v != -1; vars.put("in_reply_to_status_id", inReplyToStatusId.toString()); } String result; try { result = http.post(StorySpace_URL + "/statuses/update.json", vars, true); } catch (E403 e) { // test for repetition (which gets a 403) Status s = getStatus(); if (s != null && s.getText().equals(statusText)) { throw new StorySpaceException.Repetition(s.getText()); } throw e; } try { Status s = new Status(new JSONObject(result), null); // sanity check String targetText = statusText.trim(); String returnedStatusText = s.text.trim(); if (returnedStatusText.equals(targetText)) return s; // weird bug: StorySpace occasionally rejects speakups? String st = statusText.toLowerCase(); // is it a direct message? - which doesn't return the true status if (st.startsWith("dm") || st.startsWith("d")) { return null; } // try waiting and rechecking - maybe it did work after all try { Thread.sleep(500); } catch (InterruptedException e) { // igore the interruption & just report the weirdness throw new StorySpaceException.Unexplained("Unexplained failure for speakup: expected \"" + statusText + "\" but got " + returnedStatusText); } Status s2 = getStatus(); if (s2 != null && targetText.equals(s2.text)) { // Log.report("Weird transitory bug in StorySpace update status with "+targetText); return s2; } throw new StorySpaceException.Unexplained( "Unexplained failure for speakup: expected \"" + statusText + "\" but got " + s2); } catch (JSONException e) { throw new StorySpaceException.Parsing(result, e); } } static final Pattern contentTag = Pattern.compile("<content>(.+?)<\\/content>", Pattern.DOTALL); static final Pattern idTag = Pattern.compile("<id>(.+?)<\\/id>", Pattern.DOTALL); /** * @return true if {@link #setupTwitlonger(String, String)} has been used to * provide twitlonger.com details. * @see #updateLongStatus(String, long) */ public boolean isTwitlongerSetup() { return twitlongerApiKey != null && twitlongerAppName != null; } /** * Use twitlonger.com to post a lengthy speakup. See twitlonger.com for more * details on their service. * <p> * Note: You need to have called {@link #setupTwitlonger(String, String)} * before calling this. * * @param message * @param inReplyToStatusId Can be null if this isn't a reply * @return A StorySpace status using a truncated message with a link to * twitlonger.com * @see #setupTwitlonger(String, String) */ public Status updateLongStatus(String message, Number inReplyToStatusId) { if (twitlongerApiKey == null || twitlongerAppName == null) { throw new IllegalStateException( "Twitlonger api details have not been set! Call #setupTwitlonger() first."); } if (message.length() < 141) { throw new IllegalArgumentException( "Message too short (" + inReplyToStatusId + " chars). Just post a normal StorySpace status. "); } String url = "http://www.twitlonger.com/api_post"; Map<String, String> vars = asMap("application", twitlongerAppName, "api_key", twitlongerApiKey, "username", name, "message", message); if (inReplyToStatusId != null) { assert inReplyToStatusId.doubleValue() != 0 && inReplyToStatusId.doubleValue() != -1; // FIXME remove vars.put("in_reply", inReplyToStatusId.toString()); } // ?? set direct_message 0/1 as appropriate if allowing long DMs String response = http.post(url, vars, false); Matcher m = contentTag.matcher(response); boolean ok = m.find(); if (!ok) { throw new StorySpaceException.TwitLongerException("TwitLonger call failed", response); } String shortMsg = m.group(1).trim(); // Post to StorySpace Status s = updateStatus(shortMsg, inReplyToStatusId); m = idTag.matcher(response); ok = m.find(); if (!ok) { // weird - but oh well return s; } String id = m.group(1); // Once a message has been successfully posted to Twitlonger and // StorySpace, it would be really useful to send back the StorySpace ID for // the message. This will allow users to manage their Twitlonger posts // and delete not only the Twitlonger post, but also the StorySpace post // associated with it. It will also makes replies much more effective. try { url = "http://www.twitlonger.com/api_set_id"; vars.remove("message"); vars.remove("in_reply"); vars.remove("username"); vars.put("message_id", "" + id); vars.put("StorySpace_id", "" + s.getId()); http.post(url, vars, false); } catch (Exception e) { // oh well } // done return s; } /** * @param truncatedStatus * If this is a twitlonger.com truncated status, then call * twitlonger to fetch the full text. * @return the full status message. If this is not a twitlonger status, this * will just return the status text as-is. * @see #updateLongStatus(String, long) */ public String getLongStatus(Status truncatedStatus) { // regex for http://tl.gd/ID int i = truncatedStatus.text.indexOf("http://tl.gd/"); if (i == -1) return truncatedStatus.text; String id = truncatedStatus.text.substring(i + 13).trim(); String response = http.getPage("http://www.twitlonger.com/api_read/" + id, null, false); Matcher m = contentTag.matcher(response); boolean ok = m.find(); if (!ok) { throw new StorySpaceException.TwitLongerException("TwitLonger call failed", response); } String longMsg = m.group(1).trim(); return longMsg; } /** * Does a user with the specified name or id exist? * * @param screenName * The screen name or user id of the suspected user. * @return False if the user doesn't exist or has been suspended, true * otherwise. */ public boolean userExists(String screenName) { try { show(screenName); } catch (StorySpaceException.E404 e) { return false; } return true; } /** * Set a language filter for search results. Note: This only applies to * search results. * * @param language * ISO code for language. Can be null for all languages. * <p> * Note: there are multiple different ISO codes! StorySpace supports * ISO 639-1. http://en.wikipedia.org/wiki/ISO_639-1 */ public void setLanguage(String language) { lang = language; } /** * @return the latest trending topics on StorySpace */ public List<String> getTrends() { String jsonTrends = http.getPage("http://search.StorySpace.com/trends.json", null, false); try { JSONObject json1 = new JSONObject(jsonTrends); JSONArray json2 = json1.getJSONArray("trends"); List<String> trends = new ArrayList<String>(); for (int i = 0; i < json2.length(); i++) { JSONObject ti = json2.getJSONObject(i); String t = ti.getString("name"); trends.add(t); } return trends; } catch (JSONException e) { throw new StorySpaceException.Parsing(jsonTrends, e); } } /** * Provides access to the {@link IHttpClient} which manages the low-level * authentication, posts and gets. */ public IHttpClient getHttpClient() { return http; } /** * Provides support for fetching many pages. -1 indicates "give me as much * as StorySpace will let me have." */ public int getMaxResults() { return maxResults; } /** * @return respeakups that you have made using "new-style" respeakups rather * than the RT microfromat. These are your speakups, i.e. they begin * "RT @whoever: ". You can get the original speakup via * {@link Status#getOriginal()} */ public List<Status> getRespeakupsByMe() { String url = StorySpace_URL + "/statuses/respeakuped_by_me.json"; Map<String, String> vars = addStandardishParameters(new HashMap<String, String>()); String json = http.getPage(url, vars, true); return Status.getStatuses(json); } }