/*
* 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();
}
}