diff --git a/client-protocol/ApiCallbackMessage.ts b/client-protocol/ApiCallbackMessage.ts new file mode 100644 index 0000000..dc4f58f --- /dev/null +++ b/client-protocol/ApiCallbackMessage.ts @@ -0,0 +1,15 @@ +type ApiCallbackMessage = { + msg: string, + /** + * 200: 成功 + * 400: 伺服器端無法理解客戶端請求 + * 401: 需要身份驗證 + * 403: 伺服器端拒絕執行客戶端請求 + * 404: Not Found + * 500: 伺服器端錯誤 + * 501: 伺服器端不支持請求的功能 + */ + code: 200 | 400 | 401 | 403 | 404 | 500 | 501 | -1, + data?: { [key: string]: unknown }, +} +export default ApiCallbackMessage diff --git a/client-protocol/ApiDeclare.ts b/client-protocol/ApiDeclare.ts new file mode 100644 index 0000000..6245120 --- /dev/null +++ b/client-protocol/ApiDeclare.ts @@ -0,0 +1,47 @@ +export type CallMethod = + "User.auth" | + "User.register" | + "User.login" | + "User.refreshAccessToken" | + + "User.setAvatar" | + "User.updateProfile" | + "User.getMyInfo" | + "User.resetPassword" | + + "User.getInfo" | + + "User.getMyContacts" | + "User.addContacts" | + "User.removeContacts" | + + "User.getMyRecentChats" | + + "Chat.getInfo" | + + "Chat.updateSettings" | + "Chat.setAvatar" | + + "Chat.createGroup" | + + "Chat.getIdForPrivate" | + "Chat.getAnotherUserIdFromPrivate" | + + "Chat.processJoinRequest" | + "Chat.sendJoinRequest" | + "Chat.getJoinRequests" | + + "Chat.sendMessage" | + "Chat.getMessageHistory" | + + "Chat.uploadFile" + +export type ClientEvent = + "Client.onMessage" + +export const CallableMethodBeforeAuth = [ + "User.auth", + "User.register", + "User.login", + "User.refreshAccessToken", +] \ No newline at end of file diff --git a/client-protocol/BaseClientObject.ts b/client-protocol/BaseClientObject.ts new file mode 100644 index 0000000..2cf510d --- /dev/null +++ b/client-protocol/BaseClientObject.ts @@ -0,0 +1,8 @@ +import LingChairClient from "./LingChairClient.ts" + +export default class BaseClientObject { + declare client: LingChairClient + constructor(client: LingChairClient) { + this.client = client + } +} \ No newline at end of file diff --git a/client-protocol/CallbackError.ts b/client-protocol/CallbackError.ts new file mode 100644 index 0000000..c681760 --- /dev/null +++ b/client-protocol/CallbackError.ts @@ -0,0 +1,7 @@ +import ApiCallbackMessage from "./ApiCallbackMessage.ts" + +export default class CallbackError extends Error { + constructor(re: ApiCallbackMessage) { + super(`[${re.code}] ${re.msg}${re.data ? ` (data: ${JSON.stringify(re.data)})` : ''}`) + } +} \ No newline at end of file diff --git a/client-protocol/LingChairClient.ts b/client-protocol/LingChairClient.ts new file mode 100644 index 0000000..cd663a8 --- /dev/null +++ b/client-protocol/LingChairClient.ts @@ -0,0 +1,114 @@ +// deno-lint-ignore-file no-explicit-any +import { io, ManagerOptions, Socket, SocketOptions } from 'socket.io-client' +import crypto from 'node:crypto' +import { CallMethod, ClientEvent } from './ApiDeclare.ts' +import ApiCallbackMessage from './ApiCallbackMessage.ts' +import User from "./User.ts" +import UserMySelf from "./UserMySelf.ts" + +export { + User, + UserMySelf, +} + +export default class LingChairClient { + declare client: Socket + declare access_token: string + declare refresh_token?: string + constructor(args: { + server_url: string + device_id: string, + io?: Partial + }) { + this.client = io(args.server_url, { + transports: ["polling", "websocket", "webtransport"], + ...args.io, + auth: { + ...args.io?.auth, + device_id: args.device_id, + session_id: crypto.randomUUID(), + }, + }) + this.client.on("The_White_Silk", (name: string, data: unknown, _callback: (ret: unknown) => void) => { + try { + if (name == null || data == null) return + this.events[name]?.forEach((v) => v(data)) + } catch (e) { + console.error(e) + } + }) + } + connect() { + this.client.connect() + } + disconnect() { + this.client.disconnect() + } + reconnect() { + this.disconnect() + this.connect() + } + invoke(method: CallMethod, args: object = {}, timeout: number = 10000): Promise { + return new Promise((resolve) => { + this.client!.timeout(timeout).emit("The_White_Silk", method, args, (err: Error, res: ApiCallbackMessage) => { + // 错误处理 + if (err) return resolve({ + code: -1, + msg: err.message, + }) + resolve(res) + }) + }) + } + events: { [key: string]: ((data: any) => void)[] } = {} + on(eventName: ClientEvent, func: (data: any) => void) { + if (this.events[eventName] == null) + this.events[eventName] = [] + if (this.events[eventName].indexOf(func) == -1) + this.events[eventName].push(func) + } + off(eventName: ClientEvent, func: (data: any) => void) { + if (this.events[eventName] == null) + this.events[eventName] = [] + const index = this.events[eventName].indexOf(func) + if (index != -1) + this.events[eventName].splice(index, 1) + } + async auth(args: { + refresh_token?: string, + access_token?: string, + account?: string, + password?: string, + }) { + if ((!args.access_token && !args.refresh_token) && (!args.account && !args.password)) + throw new Error('Access/Refresh token or account & password required') + + this.refresh_token = args.refresh_token + + let access_token = args.access_token + if (!access_token && args.refresh_token) { + const re = await this.invoke('User.refreshAccessToken', { + refresh_token: args.refresh_token, + }) + if (re.code == 200) { + access_token = re.data!.access_token as string | undefined + } else return re + } + + if (!access_token && (args.account && args.password)) { + const re = await this.invoke('User.login', { + account: args.account, + password: crypto.createHash('sha256').update(args.password).digest('hex'), + }) + if (re.code == 200) { + access_token = re.data!.access_token as string | undefined + } else return re + } + + const re = await this.invoke('User.auth', { + access_token: access_token + }) + if (re.code == 200) this.access_token = access_token as string + return re + } +} diff --git a/client-protocol/User.ts b/client-protocol/User.ts new file mode 100644 index 0000000..216a999 --- /dev/null +++ b/client-protocol/User.ts @@ -0,0 +1,50 @@ +import BaseClientObject from "./BaseClientObject.ts" +import UserBean from "./bean/UserBean.ts" +import CallbackError from "./CallbackError.ts" +import LingChairClient from "./LingChairClient.ts" + +export default class User extends BaseClientObject { + declare bean: UserBean + constructor(client: LingChairClient, bean: UserBean) { + super(client) + this.bean = bean + } + /* + * ================================================ + * 实例化方法 + * ================================================ + */ + static async getById(client: LingChairClient, id: string) { + try { + return await this.getByIdOrThrow(client, id) + } catch(_) { + return null + } + } + static async getByIdOrThrow(client: LingChairClient, id: string) { + const re = await client.invoke("User.getInfo", { + token: client.access_token, + target: id, + }) + if (re.code == 200) + return new User(client, re.data as unknown as UserBean) + throw new CallbackError(re) + } + /* + * ================================================ + * 基本 Bean + * ================================================ + */ + getId() { + return this.bean.id + } + getUserName() { + return this.bean.username + } + getNickName() { + return this.bean.nickname + } + getAvatarFileHash() { + return this.bean.avatar_file_hash + } +} diff --git a/client-protocol/UserMySelf.ts b/client-protocol/UserMySelf.ts new file mode 100644 index 0000000..cb130e1 --- /dev/null +++ b/client-protocol/UserMySelf.ts @@ -0,0 +1,177 @@ +import CallbackError from "./CallbackError.ts" +import LingChairClient from "./LingChairClient.ts" +import User from "./User.ts" +import ChatBean from "./bean/ChatBean.ts" +import RecentChatBean from "./bean/RecentChatBean.ts" +import UserBean from "./bean/UserBean.ts" + +export default class UserMySelf extends User { + /* + * ================================================ + * 实例化方法 + * ================================================ + */ + static async getMySelf(client: LingChairClient) { + try { + return await this.getMySelfOrThrow(client) + } catch (_) { + return null + } + } + static async getMySelfOrThrow(client: LingChairClient) { + const re = await client.invoke("User.getMyInfo", { + token: client.access_token, + }) + if (re.code == 200) + return new UserMySelf(client, re.data as unknown as UserBean) + throw new CallbackError(re) + } + /* + * ================================================ + * 账号相关 + * ================================================ + */ + async resetPassword(old_password: string, new_password: string) { + try { + await this.resetPasswordOrThrow(old_password, new_password) + return true + } catch (_) { + return false + } + } + async resetPasswordOrThrow(old_password: string, new_password: string) { + const re = await this.client.invoke("User.resetPassword", { + token: this.client.access_token, + old_password, + new_password, + }) + if (re.code != 200) throw new CallbackError(re) + } + /* + * ================================================ + * 个人资料 + * ================================================ + */ + async setAvatarFileHash(file_hash: string) { + try { + await this.setAvatarFileHashOrThrow(file_hash) + return true + } catch (_) { + return false + } + } + async setAvatarFileHashOrThrow(file_hash: string) { + const re = await this.client.invoke("User.setAvatar", { + token: this.client.access_token, + file_hash, + }) + if (re.code != 200) throw new CallbackError(re) + } + async setUserName(user_name: string) { + return await this.updateProfile({ username: user_name }) + } + async setUserNameOrThrow(user_name: string) { + await this.updateProfileOrThrow({ username: user_name }) + } + async setNickName(nick_name: string) { + return await this.updateProfile({ nickname: nick_name }) + } + async setNickNameOrThrow(nick_name: string) { + await this.updateProfileOrThrow({ nickname: nick_name }) + } + async updateProfile(args: { + username?: string, + nickname?: string + }) { + try { + await this.updateProfileOrThrow(args) + return true + } catch (_) { + return false + } + } + async updateProfileOrThrow({ + username, + nickname + }: { + username?: string, + nickname?: string + }) { + const re = await this.client.invoke("User.updateProfile", { + token: this.client.access_token, + nickname, + username, + }) + if (re.code != 200) throw new CallbackError(re) + } + /* + * ================================================ + * 收藏对话 + * ================================================ + */ + async addFavouriteChats(chat_ids: string[]) { + try { + await this.addFavouriteChatsOrThrow(chat_ids) + return true + } catch (_) { + return false + } + } + async addFavouriteChatsOrThrow(chat_ids: string[]) { + const re = await this.client.invoke("User.addContacts", { + token: this.client.access_token, + targets: chat_ids, + }) + if (re.code != 200) throw new CallbackError(re) + } + async removeFavouriteChats(chat_ids: string[]) { + try { + await this.removeFavouriteChatsOrThrow(chat_ids) + return true + } catch (_) { + return false + } + } + async removeFavouriteChatsOrThrow(chat_ids: string[]) { + const re = await this.client.invoke("User.removeContacts", { + token: this.client.access_token, + targets: chat_ids, + }) + if (re.code != 200) throw new CallbackError(re) + } + async getMyFavouriteChatBeans() { + try { + return await this.getMyFavouriteChatBeansOrThrow() + } catch (_) { + return null + } + } + async getMyFavouriteChatBeansOrThrow() { + const re = await this.client.invoke("User.getMyContacts", { + token: this.client.access_token + }) + if (re.code == 200) + return re.data!.recent_chats as ChatBean[] + throw new CallbackError(re) + } + /* + * ================================================ + * 最近对话 + * ================================================ + */ + async getMyRecentChatBeans() { + try { + return await this.getMyRecentChatBeansOrThrow() + } catch (_) { + return null + } + } + async getMyRecentChatBeansOrThrow() { + const re = await this.client.invoke("User.getMyRecentChats", { + token: this.client.access_token + }) + if (re.code == 200) + return re.data!.recent_chats as RecentChatBean[] + throw new CallbackError(re) + } +} diff --git a/client-protocol/bean/ChatBean.ts b/client-protocol/bean/ChatBean.ts new file mode 100644 index 0000000..4b1de08 --- /dev/null +++ b/client-protocol/bean/ChatBean.ts @@ -0,0 +1,14 @@ +import ChatType from "./ChatType.ts" + +export default class ChatBean { + declare type: ChatType + declare id: string + declare title: string + declare avatar_file_hash?: string + declare settings?: { [key: string]: unknown } + + declare is_member: boolean + declare is_admin: boolean + + [key: string]: unknown +} diff --git a/client-protocol/bean/ChatType.ts b/client-protocol/bean/ChatType.ts new file mode 100644 index 0000000..0ac05ed --- /dev/null +++ b/client-protocol/bean/ChatType.ts @@ -0,0 +1,3 @@ +type ChatType = 'private' | 'group' + +export default ChatType diff --git a/client-protocol/bean/GroupSettingsBean.ts b/client-protocol/bean/GroupSettingsBean.ts new file mode 100644 index 0000000..3adface --- /dev/null +++ b/client-protocol/bean/GroupSettingsBean.ts @@ -0,0 +1,14 @@ +interface GroupSettingsBean { + allow_new_member_join?: boolean + allow_new_member_from_invitation?: boolean + new_member_join_method?: 'disabled' | 'allowed_by_admin' | 'answered_and_allowed_by_admin' + answered_and_allowed_by_admin_question?: string + + // 下面两个比较特殊, 由服务端给予 + group_title: string + group_name: string + + [key: string]: unknown +} + +export default GroupSettingsBean diff --git a/client-protocol/bean/JoinRequestBean.ts b/client-protocol/bean/JoinRequestBean.ts new file mode 100644 index 0000000..eae0966 --- /dev/null +++ b/client-protocol/bean/JoinRequestBean.ts @@ -0,0 +1,8 @@ +export default class JoinRequestBean { + declare user_id: string + declare title: string + declare avatar?: string + declare reason?: string + + [key: string]: unknown +} diff --git a/client-protocol/bean/MessageBean.ts b/client-protocol/bean/MessageBean.ts new file mode 100644 index 0000000..ab89417 --- /dev/null +++ b/client-protocol/bean/MessageBean.ts @@ -0,0 +1,6 @@ +export default class MessageBean { + declare id: number + declare text: string + declare user_id: string + declare time: string +} diff --git a/client-protocol/bean/RecentChatBean.ts b/client-protocol/bean/RecentChatBean.ts new file mode 100644 index 0000000..2d6d455 --- /dev/null +++ b/client-protocol/bean/RecentChatBean.ts @@ -0,0 +1,5 @@ +import ChatBean from "./ChatBean.ts" + +export default class RecentChat extends ChatBean { + declare content: string +} diff --git a/client-protocol/bean/UserBean.ts b/client-protocol/bean/UserBean.ts new file mode 100644 index 0000000..75ce4ee --- /dev/null +++ b/client-protocol/bean/UserBean.ts @@ -0,0 +1,6 @@ +export default class UserBean { + declare id: string + declare username?: string + declare nickname: string + declare avatar_file_hash?: string +} \ No newline at end of file diff --git a/client-protocol/deno.jsonc b/client-protocol/deno.jsonc new file mode 100644 index 0000000..4cdc1bc --- /dev/null +++ b/client-protocol/deno.jsonc @@ -0,0 +1,5 @@ +{ + "imports": { + "socket.io-client": "npm:socket.io-client@4.8.1" + } +} \ No newline at end of file diff --git a/client-protocol/test.ts b/client-protocol/test.ts new file mode 100644 index 0000000..5632261 --- /dev/null +++ b/client-protocol/test.ts @@ -0,0 +1,15 @@ +import LingChairClient, { User, UserMySelf } from "./LingChairClient.ts" +import OnMessageData from "./type/OnMessageData.ts" + +const client = new LingChairClient({ + server_url: 'ws://localhost:3601', + device_id: 'test01' +}) +await client.auth({ + account: '满月', + password: '满月', +}) +client.on('Client.onMessage', (data: OnMessageData) => { + console.log(data) +}) +console.log(await (await UserMySelf.getMySelf(client))?.getMyRecentChatBeans()) diff --git a/client-protocol/type/OnMessageData.ts b/client-protocol/type/OnMessageData.ts new file mode 100644 index 0000000..273bcb3 --- /dev/null +++ b/client-protocol/type/OnMessageData.ts @@ -0,0 +1,8 @@ +import MessageBean from '../bean/MessageBean.ts' + +interface OnMessageData { + chat: string + msg: MessageBean +} + +export default OnMessageData