/* * Copyright 2016 Sam Sun <me@samczsun.com> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.samczsun.skype4j.internal; import com.eclipsesource.json.JsonArray; import com.eclipsesource.json.JsonObject; import com.eclipsesource.json.JsonValue; import com.samczsun.skype4j.Skype; import com.samczsun.skype4j.Visibility; import com.samczsun.skype4j.chat.Chat; import com.samczsun.skype4j.chat.GroupChat; import com.samczsun.skype4j.events.EventDispatcher; import com.samczsun.skype4j.exceptions.*; import com.samczsun.skype4j.exceptions.handler.ErrorHandler; import com.samczsun.skype4j.exceptions.handler.ErrorSource; import com.samczsun.skype4j.internal.chat.ChatImpl; import com.samczsun.skype4j.internal.participants.info.BotInfoImpl; import com.samczsun.skype4j.internal.participants.info.ContactImpl; import com.samczsun.skype4j.internal.threads.ActiveThread; import com.samczsun.skype4j.internal.threads.AuthenticationChecker; import com.samczsun.skype4j.internal.threads.PollThread; import com.samczsun.skype4j.internal.threads.ServerPingThread; import com.samczsun.skype4j.internal.utils.Encoder; import com.samczsun.skype4j.participants.info.BotInfo; import com.samczsun.skype4j.participants.info.Contact; import org.jsoup.helper.Validate; import java.io.*; import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.*; import java.util.logging.Formatter; import java.util.regex.Matcher; import java.util.regex.Pattern; public abstract class SkypeImpl implements Skype { public static final String LINE_SEPARATOR = System.getProperty("line.separator"); public static final Pattern PAGE_SIZE_PATTERN = Pattern.compile("pageSize=([0-9]+)"); public static final String VERSION = "0.2.0-SNAPSHOT"; protected final AtomicBoolean loggedIn = new AtomicBoolean(false); protected final AtomicBoolean shutdownRequested = new AtomicBoolean(false); protected final AtomicBoolean subscribed = new AtomicBoolean(false); protected final UUID guid = UUID.randomUUID(); protected final Set<String> resources; protected final List<ErrorHandler> errorHandlers; private final String username; protected ExecutorService scheduler; protected ExecutorService shutdownThread; protected EventDispatcher eventDispatcher = new SkypeEventDispatcher(this); protected Map<String, String> cookies = new HashMap<>(); protected ServerPingThread serverPingThread; protected ActiveThread activeThread; protected AuthenticationChecker reauthThread; protected PollThread pollThread; protected SkypeWebSocket wss; protected String conversationBackwardLink; protected String conversationSyncState; protected Logger logger = Logger.getLogger(Skype.class.getCanonicalName()); private String skypeToken; private long skypeTokenExpiryTime; private String registrationToken; private long registrationTokenExpiryTime; private String cloud = ""; private String endpointId; private JsonObject trouterData; private int socketId = 1; // Data protected final Map<String, ChatImpl> allChats = Collections.synchronizedMap(new HashMap<>()); protected final Map<String, Contact> allContacts = Collections.synchronizedMap(new HashMap<>()); protected final Map<String, BotInfoImpl> allBots = Collections.synchronizedMap(new HashMap<>()); protected final Set<Contact.ContactRequest> allContactRequests = Collections.synchronizedSet(new HashSet<>()); public SkypeImpl(String username, Set<String> resources, Logger logger, List<ErrorHandler> errorHandlers) { this.username = username; this.resources = Collections.unmodifiableSet(new HashSet<>(resources)); this.errorHandlers = Collections.unmodifiableList(new ArrayList<>(errorHandlers)); if (logger != null) { this.logger = logger; } else { Handler handler = new ConsoleHandler(); handler.setFormatter(new Formatter() { @Override public String format(LogRecord record) { StringBuilder sb = new StringBuilder(); sb.append("[").append(record.getLevel().getLocalizedName()).append("] "); sb.append("[").append(new Date(record.getMillis())).append("] "); sb.append(formatMessage(record)).append(LINE_SEPARATOR); if (record.getThrown() != null) { StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw); record.getThrown().printStackTrace(pw); pw.close(); sb.append(sw.toString()); } return sb.toString(); } }); this.logger.setUseParentHandlers(false); this.logger.addHandler(handler); } } @Override public void login() throws ConnectionException, InvalidCredentialsException { Endpoints.ELIGIBILITY_CHECK.open(this) .expect(200, "You are not eligible to use Skype for Web!") .get(); this.loggedIn.set(true); if (this.serverPingThread != null) { this.serverPingThread.kill(); this.serverPingThread = null; } if (this.reauthThread != null) { this.reauthThread.kill(); this.reauthThread = null; } if (scheduler != null) { scheduler.shutdownNow(); while (!scheduler.isTerminated()) ; } shutdownThread = Executors.newSingleThreadExecutor(new SkypeThreadFactory(this, "Shutdown")); scheduler = Executors.newFixedThreadPool(4, new SkypeThreadFactory(this, "Poller")); (serverPingThread = new ServerPingThread(this)).start(); (reauthThread = new AuthenticationChecker(this)).start(); } public List<Chat> loadMoreChats(int amount) throws ConnectionException { try { JsonObject data; if (this.conversationBackwardLink == null) { if (this.conversationSyncState == null) { InputStream input = Endpoints.LOAD_CHATS .open(this, System.currentTimeMillis(), amount) .as(InputStream.class) .expect(200, "While loading chats") .get(); data = Utils.parseJsonObject(input); } else { return Collections.emptyList(); } } else { Matcher matcher = PAGE_SIZE_PATTERN.matcher(this.conversationBackwardLink); matcher.find(); String url = matcher.replaceAll("pageSize=" + amount); data = Endpoints .custom(url, this) .as(JsonObject.class) .expect(200, "While loading chats") .header("RegistrationToken", this.getRegistrationToken()) .get(); } List<Chat> chats = new ArrayList<>(); for (JsonValue value : data.get("conversations").asArray()) { try { chats.add(this.getOrLoadChat(value.asObject().get("id").asString())); } catch (ChatNotFoundException e) { throw new RuntimeException(e); } catch (IllegalArgumentException e) { handleError(null, new RuntimeException(value.toString(), e), false); } } JsonObject metadata = data.get("_metadata").asObject(); if (metadata.get("backwardLink") != null) { this.conversationBackwardLink = metadata.get("backwardLink").asString(); } else { this.conversationBackwardLink = null; } this.conversationSyncState = metadata.get("syncState").asString(); return chats; } catch (IOException e) { throw ExceptionHandler.generateException("While loading chats", e); } } protected JsonObject buildSubscriptionObject() { JsonObject subscriptionObject = new JsonObject(); subscriptionObject.add("channelType", "httpLongPoll"); subscriptionObject.add("template", "raw"); JsonArray interestedResources = new JsonArray(); this.resources.forEach(interestedResources::add); subscriptionObject.add("interestedResources", interestedResources); return subscriptionObject; } protected JsonObject buildRegistrationObject() { JsonObject registrationObject = new JsonObject(); registrationObject.add("id", "messagingService"); registrationObject.add("type", "EndpointPresenceDoc"); registrationObject.add("selfLink", "uri"); JsonObject publicInfo = new JsonObject(); publicInfo.add("capabilities", "video|audio"); publicInfo.add("type", 1); publicInfo.add("skypeNameVersion", "skype.com"); publicInfo.add("nodeInfo", ""); publicInfo.add("version", Skype.VERSION); JsonObject privateInfo = new JsonObject(); privateInfo.add("epname", "Skype4J"); registrationObject.add("publicInfo", publicInfo); registrationObject.add("privateInfo", privateInfo); return registrationObject; } public void shutdown() { if (this.loggedIn.get()) { loggedIn.set(false); shutdownRequested.set(true); this.shutdownThread.submit(() -> { shutdownThread.shutdown(); reauthThread.kill(); scheduler.shutdownNow(); while (!scheduler.isTerminated()) ; doShutdown(); }); } } public void doShutdown() { if (this.pollThread != null) { this.pollThread.shutdown(); this.pollThread = null; } if (this.serverPingThread != null) { this.serverPingThread.kill(); this.serverPingThread = null; } if (this.activeThread != null) { this.activeThread.kill(); this.activeThread = null; } if (this.reauthThread != null) { this.reauthThread.kill(); this.reauthThread = null; } if (this.wss != null) { this.wss.close(); this.wss = null; } } protected void updateCloud(String anyLocation) { Pattern grabber = Pattern.compile("https?://([^-]*-)client-s"); Matcher m = grabber.matcher(anyLocation); if (m.find()) { this.cloud = m.group(1); } } @Override public Chat getChat(String name) { return allChats.get(name); } @Override public ChatImpl loadChat(String name) throws ConnectionException, ChatNotFoundException { if (!allChats.containsKey(name)) { ChatImpl chat = Factory.createChat(this, name); allChats.put(name, chat); return chat; } else { throw new IllegalArgumentException("Chat already exists"); } } @Override public ChatImpl getOrLoadChat(String name) throws ConnectionException, ChatNotFoundException { if (allChats.containsKey(name)) { return allChats.get(name); } else { return loadChat(name); } } @Override public GroupChat joinChat(String id) throws ConnectionException, ChatNotFoundException, NoPermissionException { Validate.isTrue(id.startsWith("19:") && id.endsWith("@thread.skype"), "Invalid chat id"); JsonObject obj = new JsonObject(); obj.add("role", "User"); Endpoints.ADD_MEMBER_URL.open(this, id, getUsername()).on(403, (connection) -> { throw new NoPermissionException(); }).on(404, (connection) -> { throw new ChatNotFoundException(); }).expect(200, "While joining chat").put(obj); return (GroupChat) getOrLoadChat(id); } @Override public Contact getContact(String name) { return this.allContacts.get(name); } @Override public Contact loadContact(String name) throws ConnectionException { if (!allContacts.containsKey(name)) { Contact contact = ContactImpl.createContact(this, name); allContacts.put(name, contact); return contact; } else { throw new IllegalArgumentException("Contact already exists"); } } @Override public Contact getOrLoadContact(String username) throws ConnectionException { Contact contact = allContacts.get(username); if (contact == null) { contact = loadContact(username); allContacts.put(username, contact); } return contact; } @Override public BotInfo getOrLoadBotInfo(String botId) throws ConnectionException { BotInfoImpl botInfo = this.allBots.get(botId); if (botInfo == null) { botInfo = new BotInfoImpl(this, botId); botInfo.load(); this.allBots.put(botInfo.getId(), botInfo); } return botInfo; } protected void registerEndpoint() throws ConnectionException { Endpoints.ENDPOINTS_URL .open(this) .noRedirects() .on(301, (connection) -> Endpoints .custom(Endpoints.ENDPOINTS_URL.url() + "/" + Encoder.encode(endpointId), SkypeImpl.this) .expect(200, "While registering endpoint") .header("Authentication", "skypetoken=" + skypeToken) .header("LockAndKey", Utils.generateChallengeHeader()) .put(new JsonObject().add("endpointFeatures", "Agent"))) .expect(201, "While registering endpoint") .header("Authentication", "skypetoken=" + skypeToken) .post(new JsonObject().add("endpointFeatures", "Agent")); } public abstract void getContactRequests(boolean fromWebsocket) throws ConnectionException; public abstract void updateContactList() throws ConnectionException; public void registerWebSocket() throws ConnectionException, InterruptedException, URISyntaxException, KeyManagementException, NoSuchAlgorithmException, UnsupportedEncodingException { boolean needsToRegister = false; if (trouterData == null) { trouterData = Endpoints.TROUTER_URL .open(this) .as(JsonObject.class) .expect(200, "While fetching trouter data") .post(); needsToRegister = true; } else { Endpoints.RECONNECT_WEBSOCKET .open(this, trouterData.get("connId")) .expect(200, "Requesting websocket reconnect") .post(); } JsonObject policyResponse = Endpoints.POLICIES_URL .open(this) .as(JsonObject.class) .expect(200, "While fetching policy data") .post(new JsonObject().add("sr", trouterData.get("connId"))); Map<String, String> data = new HashMap<>(); for (JsonObject.Member value : policyResponse) { data.put(value.getName(), Utils.coerceToString(value.getValue())); } data.put("r", Utils.coerceToString(trouterData.get("instance"))); data.put("p", String.valueOf(trouterData.get("instancePort").asInt())); data.put("ccid", Utils.coerceToString(trouterData.get("ccid"))); data.put("v", "v2"); //TODO: MAGIC VALUE data.put("dom", "web.skype.com"); //TODO: MAGIC VALUE data.put("auth", "true"); //TODO: MAGIC VALUE data.put("tc", new JsonObject() .add("cv", "2015.11.05") .add("hr", "") .add("v", "1.34.99") .toString()); //TODO: MAGIC VALUE data.put("timeout", "55"); data.put("t", String.valueOf(System.currentTimeMillis())); StringBuilder args = new StringBuilder(); for (Map.Entry<String, String> entry : data.entrySet()) { args .append(URLEncoder.encode(entry.getKey(), "UTF-8")) .append("=") .append(URLEncoder.encode(entry.getValue(), "UTF-8")) .append("&"); } String socketio = Utils.coerceToString(trouterData.get("socketio")); if (socketio.endsWith("/")) { socketio = socketio.substring(0, socketio.length() - 1); } String socketURL = socketio + "/socket.io/" + socketId + "/?" + args.toString(); String websocketData = Endpoints .custom(socketURL, this) .as(String.class) .expect(200, "While fetching websocket details") .get(); if (needsToRegister) { Endpoints.REGISTRATIONS .open(this) .expect(202, "While registering websocket") .post(new JsonObject() .add("clientDescription", new JsonObject() .add("aesKey", "") .add("languageId", "en-US") .add("platform", "Chrome") .add("platformUIVersion", Skype.VERSION) .add("templateKey", "SkypeWeb_1.1")) .add("registrationId", UUID.randomUUID().toString()) .add("nodeId", "") .add("transports", new JsonObject().add("TROUTER", new JsonArray().add(new JsonObject() .add("context", "") .add("ttl", 3600) .add("path", trouterData.get("surl")))))); } this.wss = new SkypeWebSocket(this, new URI(String.format("%s/socket.io/" + socketId + "/websocket/%s?%s", "wss://" + socketio.replaceAll("https?://", "").replace(":443", ""), websocketData.split(":")[0], args.toString()))); this.wss.connectBlocking(); socketId++; } public void subscribe() throws ConnectionException { try { HttpURLConnection connection = Endpoints.SUBSCRIPTIONS_URL .open(this) .dontConnect() .post(buildSubscriptionObject()); if (connection.getResponseCode() == 404) { if (connection.getHeaderField("Set-RegistrationToken") != null) { setRegistrationToken(connection.getHeaderField("Set-RegistrationToken")); } Endpoints .custom("https://" + this.getCloud() + "client-s.gateway.messenger.live.com/v1/users/ME/endpoints/" + Encoder .encode(endpointId), this) .header("RegistrationToken", getRegistrationToken()) .expect(200, "Err") .put(new JsonObject().add("endpointFeatures", "Agent")); connection = Endpoints.SUBSCRIPTIONS_URL.open(this).dontConnect().post(buildSubscriptionObject()); } if (connection.getResponseCode() != 201) { throw ExceptionHandler.generateException("While subscribing", connection); } Endpoints.MESSAGINGSERVICE_URL .open(this, Encoder.encode(endpointId)) .expect(200, "While submitting messagingservice") .put(buildRegistrationObject()); if (this.pollThread != null) { this.pollThread.shutdown(); this.pollThread = null; } (pollThread = new PollThread(this, Encoder.encode(endpointId))).start(); subscribed.set(true); } catch (IOException io) { throw ExceptionHandler.generateException("While subscribing", io); } catch (ConnectionException e) { throw e; } catch (Exception e) { throw new RuntimeException(e); } } public void reauthenticate() throws ConnectionException, InvalidCredentialsException, NotParticipatingException { //todo: keep subscribed until reauth is finished so events aren't lost doShutdown(); login(); if (subscribed.get()) { subscribe(); } } public String getRegistrationToken() { return this.registrationToken; } public void setRegistrationToken(String registrationToken) { String[] splits = registrationToken.split(";"); this.registrationToken = splits[0]; this.registrationTokenExpiryTime = Long.parseLong(splits[1].substring("expires=".length() + 1)) * 1000; if (splits.length > 2) { this.endpointId = splits[2].split("=")[1]; if (this.activeThread != null) { this.activeThread.kill(); this.activeThread = null; } (activeThread = new ActiveThread(this, Encoder.encode(endpointId))).start(); } } public String getSkypeToken() { return this.skypeToken; } public void setSkypeToken(String skypeToken) { this.skypeToken = skypeToken; String[] data = skypeToken.split("\\."); JsonObject object = JsonObject.readFrom( new String(Base64.getDecoder().decode(data[1]), StandardCharsets.UTF_8)); this.skypeTokenExpiryTime = object.get("exp").asLong() * 1000; } public String getCloud() { return this.cloud; } public Map<String, String> getCookies() { return this.cookies; } public EventDispatcher getEventDispatcher() { return this.eventDispatcher; } public boolean isShutdownRequested() { return this.shutdownRequested.get(); } public Logger getLogger() { return this.logger; } public boolean isLoggedIn() { return loggedIn.get(); } public ExecutorService getScheduler() { return this.scheduler; } public String getUsername() { return this.username; } public UUID getGuid() { return guid; } @Override public Collection<Chat> getAllChats() { return Collections.unmodifiableCollection(this.allChats.values()); } @Override public Collection<Contact> getAllContacts() { return Collections.unmodifiableCollection(this.allContacts.values()); } public void handleError(ErrorSource errorSource, Throwable throwable, boolean shutdown) { for (ErrorHandler handler : errorHandlers) { try { handler.handle(errorSource, throwable, shutdown); } catch (Throwable ignored) { } } if (shutdown) { shutdown(); } } protected HttpURLConnection getAsmToken() throws ConnectionException { return Endpoints.TOKEN_AUTH_URL .open(this) .as(HttpURLConnection.class) .cookies(cookies) .header("Content-Type", "application/x-www-form-encoded") .expect(204, "While fetching asmtoken") .post("skypetoken=" + Encoder.encode(skypeToken)); } public boolean isAuthenticated() { return System.currentTimeMillis() < skypeTokenExpiryTime; } public boolean isRegistrationTokenValid() { return System.currentTimeMillis() < registrationTokenExpiryTime; } public long getExpirationTime() { return skypeTokenExpiryTime; } public SkypeWebSocket getWebSocket() { return wss; } public void setVisibility(Visibility visibility) throws ConnectionException { Endpoints.VISIBILITY .open(this) .expect(200, "While updating visibility") .put(new JsonObject().add("status", visibility.internalName())); } public String getId() { return "8:" + getUsername(); } }