diff --git a/.gitignore b/.gitignore index 4ed15a2..10638a1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # JetBrains products (Webstorm, IntelliJ IDEA, ...) .idea/ *.iml +.vscode ############################################################################### # Build # diff --git a/package-lock.json b/package-lock.json index 8bca7a7..71bf162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8571,14 +8571,6 @@ "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", "dev": true }, - "string_decoder": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", - "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", - "requires": { - "safe-buffer": "5.1.1" - } - }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -8590,6 +8582,14 @@ "strip-ansi": "3.0.1" } }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "requires": { + "safe-buffer": "5.1.1" + } + }, "stringstream": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", diff --git a/src/lib/api.ts b/src/lib/api.ts index 62d0136..850fcbc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,11 +1,15 @@ import events from "events"; import { acceptContactRequest } from "./api/accept-contact-request"; +import { addMemberToConversation } from "./api/add-member"; +import { createConversation } from "./api/create-conversation"; import { declineContactRequest } from "./api/decline-contact-request"; import { getContact } from "./api/get-contact"; import { getConversation } from "./api/get-conversation"; import { getConversations } from "./api/get-conversations"; +import { getJoinUrl } from "./api/get-join-url"; import { sendImage } from "./api/send-image"; import { sendMessage } from "./api/send-message"; +import { setConversationTopic } from "./api/set-conversation-topic"; import { setStatus } from "./api/set-status"; import { ContactsInterface, ContactsService } from "./contacts/contacts"; import * as api from "./interfaces/api/api"; @@ -14,6 +18,7 @@ import { Context as ApiContext } from "./interfaces/api/context"; import { Conversation } from "./interfaces/api/conversation"; import * as apiEvents from "./interfaces/api/events"; import { HttpIo } from "./interfaces/http-io"; +import { AllUsers } from "./interfaces/native-api/conversation"; import { MessagesPoller } from "./polling/messages-poller"; import { Contact } from "./types/contact"; import { Invite } from "./types/invite"; @@ -74,6 +79,22 @@ export class Api extends events.EventEmitter implements ApiEvents { return sendMessage(this.io, this.context, message, conversationId); } + async setConversationTopic(conversationId: string, topic: string): Promise { + return setConversationTopic(this.io, this.context, conversationId, topic); + } + + async getJoinUrl(conversationId: string): Promise { + return getJoinUrl(this.io, this.context, conversationId); + } + + async addMemberToConversation(conversationId: string, memberId: string): Promise { + return addMemberToConversation(this.io, this.context, conversationId, memberId); + } + + async createConversation(allUsers: AllUsers): Promise { + return createConversation(this.io, this.context, allUsers); + } + async sendImage(message: api.NewImage, conversationId: string): Promise { return sendImage(this.io, this.context, message, conversationId); } diff --git a/src/lib/api/add-member.ts b/src/lib/api/add-member.ts new file mode 100644 index 0000000..e159419 --- /dev/null +++ b/src/lib/api/add-member.ts @@ -0,0 +1,37 @@ +import { Incident } from "incident"; +import { Context } from "../interfaces/api/context"; +import * as io from "../interfaces/http-io"; +import * as messagesUri from "../messages-uri"; + +interface RequestBody { + role: "User" | "Admin" | string; +} + +export async function addMemberToConversation( + io: io.HttpIo, + apiContext: Context, + memberId: string, + converstionId: string, + role = "User", +): Promise { + + // `https://{host}}/v1/threads/${converstionId}/members/${memberId}`, + const uri: string = messagesUri.member(apiContext.registrationToken.host, converstionId, memberId); + + const requestBody: RequestBody = { role }; + const requestOptions: io.PutOptions = { + uri, + cookies: apiContext.cookies, + body: JSON.stringify(requestBody), + headers: { + "RegistrationToken": apiContext.registrationToken.raw, + "Content-type": "application/json", + }, + }; + + const res: io.Response = await io.put(requestOptions); + + if (res.statusCode !== 200) { + return Promise.reject(new Incident("add-member", "Received wrong return code")); + } +} diff --git a/src/lib/api/create-conversation.ts b/src/lib/api/create-conversation.ts new file mode 100644 index 0000000..0b21e91 --- /dev/null +++ b/src/lib/api/create-conversation.ts @@ -0,0 +1,53 @@ +import { Incident } from "incident"; +import { Context } from "../interfaces/api/context"; +import * as io from "../interfaces/http-io"; +import { AllUsers, Members } from "../interfaces/native-api/conversation"; +import * as messagesUri from "../messages-uri"; +import { getMembers } from "../utils"; + +interface RequestBody { + members: any[]; +} + +export async function createConversation( + io: io.HttpIo, + apiContext: Context, + allUsers: AllUsers, +): Promise { + + // Each member object consists of an ``id`` (user thread identifier), and role (either ``Admin`` or ``User``). + const members: Members[] = getMembers(allUsers); + const requestBody: RequestBody = { + members, + }; + + const uri: string = messagesUri.threads(apiContext.registrationToken.host); + + const requestOptions: io.PostOptions = { + uri, + cookies: apiContext.cookies, + body: JSON.stringify(requestBody), + headers: { + RegistrationToken: apiContext.registrationToken.raw, + Location: "/", + }, + }; + + const res: io.Response = await io.post(requestOptions); + + if (res.statusCode !== 201) { + throw new Incident("create-conversation", "Received wrong return code"); + } + + const location: string | undefined = res.headers.location; + if (location === undefined) { + throw new Incident("create-conversation", "Missing `Location` response header"); + } + // TODO: Parse URL properly / more reliable checks + const id: string | undefined = location.split("/").pop(); + if (id === undefined) { + throw new Incident("create-conversation", "Unable to read conversation ID"); + } + // conversation ID + return id; +} diff --git a/src/lib/api/get-join-url.ts b/src/lib/api/get-join-url.ts new file mode 100644 index 0000000..e4291be --- /dev/null +++ b/src/lib/api/get-join-url.ts @@ -0,0 +1,37 @@ +import { Incident } from "incident"; +import { Context } from "../interfaces/api/context"; +import * as io from "../interfaces/http-io"; +import { Join } from "../interfaces/native-api/conversation"; +import * as messagesUri from "../messages-uri"; + +interface RequestBody { + baseDomain: "https://join.skype.com/launch/" | string; + threadId: string; +} + +export async function getJoinUrl(io: io.HttpIo, apiContext: Context, conversationId: string): Promise { + const requestBody: RequestBody = { + baseDomain: "https://join.skype.com/launch/", + threadId: conversationId, + }; + + const uri: string = "https://api.scheduler.skype.com/threads"; + + const requestOptions: io.PostOptions = { + uri, + cookies: apiContext.cookies, + body: JSON.stringify(requestBody), + headers: { + "X-Skypetoken": apiContext.skypeToken.value, + "Content-Type": "application/json", + }, + }; + + const res: io.Response = await io.post(requestOptions); + if (res.statusCode !== 200) { + return Promise.reject(new Incident("get-join-url", "Received wrong return code")); + } + const body: Join = JSON.parse(res.body); + + return body.JoinUrl; +} diff --git a/src/lib/api/set-conversation-topic.ts b/src/lib/api/set-conversation-topic.ts new file mode 100644 index 0000000..cc76c59 --- /dev/null +++ b/src/lib/api/set-conversation-topic.ts @@ -0,0 +1,38 @@ +import { Incident } from "incident"; +import { Context } from "../interfaces/api/context"; +import * as io from "../interfaces/http-io"; +import * as messagesUri from "../messages-uri"; + +interface RequestBody { + topic: string; +} + +export async function setConversationTopic( + io: io.HttpIo, + apiContext: Context, + conversationId: string, + topic: string, +): Promise { + + const requestBody: RequestBody = { + topic, + }; + + const uri: string = messagesUri.properties(apiContext.registrationToken.host, conversationId); + + const requestOptions: io.PutOptions = { + uri, + cookies: apiContext.cookies, + body: JSON.stringify(requestBody), + queryString: {name: "topic"}, + headers: { + "RegistrationToken": apiContext.registrationToken.raw, + "Content-type": "application/json", + }, + }; + const res: io.Response = await io.put(requestOptions); + + if (res.statusCode !== 200) { + return Promise.reject(new Incident("set-conversation-topic", "Received wrong return code")); + } +} diff --git a/src/lib/interfaces/native-api/conversation.ts b/src/lib/interfaces/native-api/conversation.ts index cfebc89..e8352a2 100644 --- a/src/lib/interfaces/native-api/conversation.ts +++ b/src/lib/interfaces/native-api/conversation.ts @@ -8,6 +8,14 @@ export interface ThreadProperties { version?: string; } +// https://github.com/OllieTerrance/SkPy.docs/blob/master/protocol/chat.rst#join-urls +export interface Join { + Blob: string; + Id: string; + JoinUrl: string; + ThreadId: string; +} + export interface Conversation { // https://{host}/v1/threads/{19:threadId} or // https://{host}/v1/users/ME/contacts/{8:contactId} targetLink: string; @@ -43,6 +51,15 @@ export interface ThreadMember { friendlyName: string; } +export interface AllUsers { + [type: string]: string[]; +} + +export interface Members { + id: string; + role: "Admin" | "User" | string; +} + export interface Thread { // "19:..." id: string; diff --git a/src/lib/messages-uri.ts b/src/lib/messages-uri.ts index 9295512..6218dcc 100644 --- a/src/lib/messages-uri.ts +++ b/src/lib/messages-uri.ts @@ -31,6 +31,21 @@ function buildThread(thread: string): string[] { return buildThreads().concat(thread); } +// /v1/threads/{thread}/properties +function buildProperties(thread: string): string[] { + return buildThread(thread).concat("properties"); +} + +// /v1/threads/{thread}/members +function buildMembers(thread: string): string[] { + return buildThread(thread).concat("members"); +} + +// /v1/threads/{thread}/members/{member} +function buildMember(thread: string, member: string): string[] { + return buildMembers(thread).concat(member); +} + // /v1/users function buildUsers(): string[] { return buildV1().concat("users"); @@ -133,10 +148,22 @@ function get(host: string, p: string) { return url.resolve(getOrigin(host), p); } +export function threads(host: string): string { + return get(host, joinPath(buildThreads())); +} + export function thread(host: string, threadId: string): string { return get(host, joinPath(buildThread(threadId))); } +export function member(host: string, threadId: string, member: string): string { + return get(host, joinPath(buildMember(threadId, member))); +} + +export function properties(host: string, threadId: string): string { + return get(host, joinPath(buildProperties(threadId))); +} + export function users(host: string): string { return get(host, joinPath(buildUsers())); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a6804c4..ea9479a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,5 @@ import _ from "lodash"; - +import { AllUsers, Members } from "./interfaces/native-api/conversation"; /** * Returns the number of seconds since epoch. * @@ -98,3 +98,13 @@ export function parseHeaderParams(params: string): Map { return result; } + +export function getMembers(allUsers: AllUsers): Members[] { + return Object.keys(allUsers).reduce( + (acc: any[], key: string) => { + const role: "Admin" | "User" | string = key === "admins" ? "Admin" : "User"; + const parsedGroup: Members[] = allUsers[key].map((id: string) => ({id, role})); + + return [...acc, ...parsedGroup]; + }, []); +}