diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..de625be --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84a9f86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +.env +package-lock.json +*.pem +.foreverignore +.vscode \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d9a76f0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# sudo docker build -t line_catan_groupbuy_ts . +# sudo docker exec -it 2e8e3995aa52 /bin/bash + +# 選擇node +FROM node:19.4.0 + +# 指定NODE_ENV為production +ENV NODE_ENV=production + +# 指定預設/工作資料夾 +WORKDIR /app + +# 只copy package.json檔案 +COPY ["package.json", "./"] + +# 安裝dependencies +# If you are building your code for production +# RUN npm ci --only=production +RUN npm install + +# copy其餘目錄及檔案 +COPY . . + +# 指定啟動container後執行命令 +CMD [ "npm", "start" ] \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..8abb1fa --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "line_catan_groupbuy_ts", + "version": "1.0.0", + "description": "", + "main": "src/app.ts", + "scripts": { + "start": "nodemon src/app.ts", + "test": "nodemon src/app.ts", + "dev": "nodemon --exec \"node --require ts-node/register --inspect=192.168.1.15:9229 src/app.ts\"", + "build": "tsc --project ./" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "@types/dateformat": "^5.0.0", + "@types/express": "^4.17.15", + "@types/mysql": "^2.15.21", + "@types/node": "^18.11.18", + "typescript": "^4.9.4" + }, + "dependencies": { + "@line/bot-sdk": "^7.5.2", + "dateformat": "^4.5.1", + "dayjs": "^1.11.7", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "fs": "^0.0.1-security", + "linebot": "^1.6.1", + "mysql": "^2.18.1", + "nodemon": "^2.0.20", + "openai": "^3.1.0", + "ts-node": "^10.9.1", + "xmlhttprequest": "^1.8.0" + } +} \ No newline at end of file diff --git a/src/DBTools.ts b/src/DBTools.ts new file mode 100644 index 0000000..b5a717a --- /dev/null +++ b/src/DBTools.ts @@ -0,0 +1,44 @@ +import mysql from "mysql"; +import Tools from "./Tools"; + +/** + * DBTools + */ +export default class DBTools { + + //#region Custom + + public static async Query(query: string): Promise { + const conn: mysql.Connection = this.connect(); + + let resp: any = null; + let run: boolean = true; + conn.query(query, function (err: mysql.MysqlError, rows: any, fields: mysql.FieldInfo[]): void { + if (err) { + console.error(`${query} Error: \n${err.message}`); + run = false; + } + resp = rows; + run = false; + }); + while (run) { + await Tools.Sleep(100); + } + conn.end(); + return resp; + } + + private static connect(): mysql.Connection { + const conn: mysql.Connection = mysql.createConnection({ + host: process.env.DB_HOST, + port: +process.env.DB_PORT, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE + }); + conn.connect(); + return conn; + } + + //#endregion +} diff --git a/src/Engine/CCExtensions/ArrayExtension.ts b/src/Engine/CCExtensions/ArrayExtension.ts new file mode 100644 index 0000000..0dab41d --- /dev/null +++ b/src/Engine/CCExtensions/ArrayExtension.ts @@ -0,0 +1,116 @@ +declare interface Array { + /** + * 移除一個值並且回傳 + * @param index + */ + ExRemoveAt(index: number): T; + /** + * 移除全部值(注意. 參考的也會被清空) + * @example + * + * let bar: number[] = [1, 2, 3]; + * let bar2: number[] = bar; + * bar.Clear(); + * console.log(bar, bar2); + * + * // { + * // "bar": [], + * // "bar2": [] + * // } + */ + Clear(): void; + /** + * 物件陣列排序,asc&key陣列長度請一樣 + * PS. boolean 帶false是先true在false + * @link JavaScript Object 排序 http://www.eion.com.tw/Blogger/?Pid=1170#:~:text=JavaScript%20Object%20排序 + * @param asc 是否升序排列(小到大) + * @param key 需排序的key(優先順序左到右)(沒有就放空) + */ + ObjectSort(asc?: boolean[], key?: string[]): any[]; + /** + * 設計給ArrayforHoldButton使用 + * Add a non persistent listener to the UnityEvent. + * @param call Callback function. + */ + AddListener(call: Function): void; +} + +Array.prototype.ExRemoveAt || Object.defineProperty(Array.prototype, "ExRemoveAt", { + enumerable: false, + value: function (index: number): any { + let item: any = this.splice(index, 1); + return item[0]; + } +}); + +Array.prototype.Clear || Object.defineProperty(Array.prototype, "Clear", { + enumerable: false, + value: function (): void { + this.length = 0; + + // let foo: number[] = [1, 2, 3]; + // let bar: number[] = [1, 2, 3]; + // let foo2: number[] = foo; + // let bar2: number[] = bar; + // foo = []; + // bar.length = 0; + // console.log(foo, bar, foo2, bar2); + + // { + // "foo": [], + // "bar": [], + // "foo2": [ + // 1, + // 2, + // 3 + // ], + // "bar2": [] + // } + } +}); + +Array.prototype.ObjectSort || Object.defineProperty(Array.prototype, "ObjectSort", { + enumerable: false, + /** + * @param asc 是否升序排列(小到大) + * @param key 需排序的key(優先順序左到右)(沒有就放空) + */ + value: function (asc: boolean[] = [true], key?: string[]): any[] { + if (this.length === 0) { + return this; + } else if (!key || key.length === 0) { + console.error(`ObjectSort key error`); + return this; + } else if (asc.length !== key.length) { + console.error(`ObjectSort key asc error asc.length: ${asc.length}, key.length: ${key.length}`); + return this; + } + for (let i: number = 0; i < key.length; i++) { + const keyname: string = key[i]; + if (this[0][keyname] === undefined) { + console.error(`ObjectSort has not key[${i}]: ${keyname}`); + return this; + } + } + let count: number = key ? key.length : 1; + let arr: any[]; + for (let i: number = count - 1; i >= 0; i--) { + arr = this.sort(function (a: any, b: any): 1 | -1 { + let mya: any = a; + let myb: any = b; + if (key) { + mya = a[key[i]]; + myb = b[key[i]]; + } + + // 加個等於數字相同不要再去排序到 + if (asc[i]) { + return mya >= myb ? 1 : -1; + } else { + return mya <= myb ? 1 : -1; + } + }); + } + return arr; + } +}); \ No newline at end of file diff --git a/src/Engine/CCExtensions/CCExtension.ts.meta b/src/Engine/CCExtensions/CCExtension.ts.meta new file mode 100644 index 0000000..1ab1865 --- /dev/null +++ b/src/Engine/CCExtensions/CCExtension.ts.meta @@ -0,0 +1,10 @@ +{ + "ver": "1.1.0", + "uuid": "b373f805-9297-4af5-8ea6-0a250649b5b0", + "importer": "typescript", + "isPlugin": false, + "loadPluginInWeb": true, + "loadPluginInNative": true, + "loadPluginInEditor": false, + "subMetas": {} +} \ No newline at end of file diff --git a/src/Engine/CCExtensions/NumberExtension.ts b/src/Engine/CCExtensions/NumberExtension.ts new file mode 100644 index 0000000..5d39881 --- /dev/null +++ b/src/Engine/CCExtensions/NumberExtension.ts @@ -0,0 +1,189 @@ + +declare interface Number { + + /** + * 金額每三位數(千)加逗號, 並且補到小數點第2位 + * 輸出 41,038,560.00 + * @param precision 補到小數點第幾位 + * @param isPadZero 是否要補零 + * */ + ExFormatNumberWithComma(precision?: number, isPadZero?: boolean): string; + /** + * 基本4位數(9,999-999B-T) + * */ + ExTransferToBMK(precision?: number,offset?: number): string; + /** + * 數字轉字串, 頭補0 + * @param size + */ + Pad(size: number): string; + /** + * 四捨五入到小數點第X位 (同server計算規則) + * @param precision + */ + ExToNumRoundDecimal(precision: number): number; + /** + * 無條件捨去到小數點第X位 + * @param precision + */ + ExToNumFloorDecimal(precision: number): number; + /** + * 無條件捨去強制保留X位小數,如:2,會在2後面補上00.即2.00 + * @param precision 補到小數點第幾位 + * @param isPadZero 是否要補零 + */ + ExToStringFloorDecimal(precision: number, isPadZero?: boolean): string; + /** + * 取整數) + */ + ExToInt():number; + /** + * 小數轉整數(支援科學符號) + */ + Float2Fixed():number; + /** + * 數字長度(支援科學符號) + */ + DigitLength():number; + + target: number; + + +} + +Number.prototype.ExFormatNumberWithComma || Object.defineProperty(Number.prototype, 'ExFormatNumberWithComma', { + enumerable: false, + value: function (precision: number = 2, isPadZero: boolean = true) { + + // let arr = String(this).split('.'); + let arr = this.ExToStringFloorDecimal(precision, isPadZero).split('.'); + let num = arr[0], result = ''; + while (num.length > 3) { + result = ',' + num.slice(-3) + result; + num = num.slice(0, num.length - 3); + } + if (num.length > 0) result = num + result; + return arr[1] ? result + '.' + arr[1] : result; + } +}) + + +Number.prototype.ExTransferToBMK || Object.defineProperty(Number.prototype, 'ExTransferToBMK', { + enumerable: false, + value: function (precision: number=2,offset: number = 0) { + /**千 */ + let MONEY_1K: number = 1000; + /**萬 */ + // let MONEY_10K: number = 10000; + /**十萬 */ + // let MONEY_100K: number = 100000; + /**百萬 */ + let MONEY_1M: number = 1000000; + /**千萬 */ + // let MONEY_10M: number = 10000000; + /**億 */ + // let MONEY_100M: number = 100000000; + /**十億 */ + let MONEY_1B: number = 1000000000; + /**百億 */ + // let MONEY_10B: number = 10000000000; + /**千億 */ + // let MONEY_100B: number = 100000000000; + /**兆 */ + // let MONEY_1T: number = 1000000000000; + offset = Math.pow(10, offset); + // if (this >= MONEY_1T * offset) { + // //(3)1,000T + // //1T~ + // return (~~(this / MONEY_1T)).ExFormatNumberWithComma(0) + "T"; + // } + if (this >= MONEY_1B * offset) { + //1,000B~900,000B + //1B~900B + return (this / MONEY_1B).ExFormatNumberWithComma(3, false) + "B"; + } + else if (this >= MONEY_1M * offset) { + //1,000M~900,000M + //1M~900M + return (this / MONEY_1M).ExFormatNumberWithComma(3, false) + "M"; + } + else if (this >= MONEY_1K * offset) { + //1,000K~900,000K + //1K~90K + return (this / MONEY_1K).ExFormatNumberWithComma(3, false) + "K"; + } + else { + //0~9,000,000 + //0~9,000 + return this.ExFormatNumberWithComma(precision); + } + } +}) +Number.prototype.Pad || Object.defineProperty(Number.prototype, 'Pad', { + enumerable: false, + value: function (size: number) { + let s = this + ""; + while (s.length < size) s = "0" + s; + return s; + } +}) +Number.prototype.ExToNumRoundDecimal || Object.defineProperty(Number.prototype, 'ExToNumRoundDecimal', { + enumerable: false, + value: function (precision: number) { + return Math.round(Math.round(this * Math.pow(10, (precision || 0) + 1)) / 10) / Math.pow(10, (precision || 0)); + } +}) +Number.prototype.ExToInt || Object.defineProperty(Number.prototype, 'ExToInt',{ + enumerable: false, + value: function (){ + return ~~this; + } +}) +Number.prototype.ExToNumFloorDecimal || Object.defineProperty(Number.prototype, 'ExToNumFloorDecimal', { + enumerable: false, + value: function (precision: number) { + let str = this.toPrecision(12); + let dotPos = str.indexOf('.'); + return dotPos == -1 ? this : +`${str.substr(0, dotPos + 1 + precision)}`; + } +}) +Number.prototype.ExToStringFloorDecimal || Object.defineProperty(Number.prototype, 'ExToStringFloorDecimal', { + enumerable: false, + value: function (precision: number, isPadZero: boolean = true) { + // 取小數點第X位 + let f = this.ExToNumFloorDecimal(precision); + let s = f.toString(); + // 補0 + if (isPadZero) { + let rs = s.indexOf('.'); + if (rs < 0) { + rs = s.length; + s += '.'; + } + while (s.length <= rs + precision) { + s += '0'; + } + } + return s; + } +}) +Number.prototype.Float2Fixed || Object.defineProperty(Number.prototype, 'Float2Fixed', { + enumerable: false, + value: function () { + if (this.toString().indexOf('e') === -1) { + return Number(this.toString().replace('.', '')); + } + const dLen = this.DigitLength(); + return dLen > 0 ? +parseFloat((this * Math.pow(10, dLen)).toPrecision(12)) : this; + } +}) +Number.prototype.DigitLength || Object.defineProperty(Number.prototype, 'DigitLength', { + enumerable: false, + value: function () { + const eSplit = this.toString().split(/[eE]/); + const len = (eSplit[0].split('.')[1] || '').length - (+(eSplit[1] || 0)); + return len > 0 ? len : 0; + } +}) + + \ No newline at end of file diff --git a/src/Engine/Number/NumberEx.ts b/src/Engine/Number/NumberEx.ts new file mode 100644 index 0000000..b9ad5e1 --- /dev/null +++ b/src/Engine/Number/NumberEx.ts @@ -0,0 +1,84 @@ +export module NumberEx { + + /** + * 检测数字是否越界,如果越界给出提示 + * @param {*number} num 输入数 + */ + function checkBoundary(num: number) { + if (_boundaryCheckingState) { + if (num > Number.MAX_SAFE_INTEGER || num < Number.MIN_SAFE_INTEGER) { + console.warn(`${num} is beyond boundary when transfer to integer, the results may not be accurate`); + } + } + } + + /** + * 精确乘法 + */ + export function times(num1: number, num2: number, ...others: number[]): number { + if (others.length > 0) { + return times(times(num1, num2), others[0], ...others.slice(1)); + } + const num1Changed = num1.Float2Fixed(); + const num2Changed = num2.Float2Fixed(); + const baseNum = num1.DigitLength() + num2.DigitLength(); + const leftValue = num1Changed * num2Changed; + + checkBoundary(leftValue); + + return leftValue / Math.pow(10, baseNum); + } + + /** + * 精确加法 + */ + export function plus(num1: number, num2: number, ...others: number[]): number { + if (others.length > 0) { + return plus(plus(num1, num2), others[0], ...others.slice(1)); + } + const baseNum = Math.pow(10, Math.max(num1.DigitLength(), num2.DigitLength())); + return (times(num1, baseNum) + times(num2, baseNum)) / baseNum; + } + + /** + * 精确减法 + */ + export function minus(num1: number, num2: number, ...others: number[]): number { + if (others.length > 0) { + return minus(minus(num1, num2), others[0], ...others.slice(1)); + } + const baseNum = Math.pow(10, Math.max(num1.DigitLength(), num2.DigitLength())); + return (times(num1, baseNum) - times(num2, baseNum)) / baseNum; + } + + /** + * 精确除法 + */ + export function divide(num1: number, num2: number, ...others: number[]): number { + if (others.length > 0) { + return divide(divide(num1, num2), others[0], ...others.slice(1)); + } + const num1Changed = num1.Float2Fixed(); + const num2Changed = num2.Float2Fixed(); + checkBoundary(num1Changed); + checkBoundary(num2Changed); + return times((num1Changed / num2Changed), Math.pow(10, num2.DigitLength() - num1.DigitLength())); + } + + /** + * 四舍五入 + */ + export function round(num: number, ratio: number): number { + const base = Math.pow(10, ratio); + return divide(Math.round(times(num, base)), base); + } + + let _boundaryCheckingState = false; + /** + * 是否进行边界检查 + * @param flag 标记开关,true 为开启,false 为关闭 + */ + function enableBoundaryChecking(flag = true) { + _boundaryCheckingState = flag; + } +} \ No newline at end of file diff --git a/src/Engine/Number/RandomEx.ts b/src/Engine/Number/RandomEx.ts new file mode 100644 index 0000000..1bfc080 --- /dev/null +++ b/src/Engine/Number/RandomEx.ts @@ -0,0 +1,90 @@ + +export module RandomEx { + + /** + * 取得隨機布林值 + */ + export function GetBool() { + return GetInt() >= 0; + } + /** + * 取得隨機整數(回傳min ~ max - 1) + * @param min + * @param max + */ + export function GetInt(min: number = Number.MIN_VALUE, max: number = Number.MAX_VALUE): number { + return Math.floor(Math.random() * (max - min)) + min; + } + /** + * 取得隨機小數 + * @param min + * @param max + */ + export function GetFloat(min: number = Number.MIN_VALUE, max: number = Number.MAX_VALUE): number { + return Math.random() * (max - min) + min; + } + /** + * 隨機取得複數個不重複回傳 + * @param num 取得數量 + * @param items 陣列 + */ + export function GetMultiNoRepeat(num: number, items: any[]): any[] { + let result: any[] = []; + for (let i: number = 0; i < num; i++) { + let ran: number = Math.floor(Math.random() * items.length); + let item = items.splice(ran, 1)[0]; + if (result.indexOf(item) == -1) { + result.push(item); + } + }; + return result; + } + + /** + * 根據權重取得複數個不重複回傳 + * @param prize 獎項 + * @param weights 機率 + * @param count 數量 + */ + export function GetMultiNoRepeatByWeight(prize: any[], weights: number[] = null, count: number = 1): any[] { + if (weights === null) { + weights = []; + for (let i: number = 0; i < prize.length; i++) { + weights.push(1); + } + } + let target: any[] = []; + for (let i: number = 0; i < count; i++) { + let results: number[] = RandomEx.GetPrizeByWeight(prize, weights); + prize.splice(results[0], 1); + weights.splice(results[0], 1); + target.push(results[1]); + } + return target; + } + + + /** + * 根據權重隨機取值 + * @param prize 獎項 + * @param weights 機率 + */ + export function GetPrizeByWeight(prize: any[], weights: number[]): any[] { + if (prize.length !== weights.length) { + console.error(`GetWeight error -> prize.length:${prize.length} !== weights.length:${weights.length}`); + return null; + } + let totalWeight: number = 0; + for (let i: number = 0; i < weights.length; i++) { + totalWeight += weights[i]; + } + let random: number = RandomEx.GetInt(0, totalWeight) + 1; + let nowWeight: number = weights[0]; + for (let i: number = 0; i < weights.length; i++) { + if (nowWeight >= random) { + return [i, prize[i]]; + } + nowWeight += weights[i + 1]; + } + } +} diff --git a/src/Engine/String.ts b/src/Engine/String.ts new file mode 100644 index 0000000..33b622e --- /dev/null +++ b/src/Engine/String.ts @@ -0,0 +1,16 @@ +interface StringConstructor { + IsNullOrEmpty: (value: string) => boolean; + Format: (format: string, ...args: any[]) => string; +} + +String.IsNullOrEmpty = function (value: string): boolean { + return value === undefined || value === null || value.trim() === ""; +}; + +String.Format = function (format: string, ...args: any[]): string { + return format.replace(/{(\d+)}/g, (match, index) => { + let value: any = args[index]; + if (value === null || value === undefined) { return ""; } + return "" + value; + }); +}; diff --git a/src/GroupBuy.ts b/src/GroupBuy.ts new file mode 100644 index 0000000..5adb793 --- /dev/null +++ b/src/GroupBuy.ts @@ -0,0 +1,212 @@ +import * as line from "@line/bot-sdk"; +import DBTools from "./DBTools"; + +/** + * GroupBuy + */ +export default class GroupBuy { + + //#region Custom + + public static async Run(event: any, msg: string[], bot: line.Client): Promise { + let message: line.Message | line.Message[] = null; + const instruction: string = msg[1]; + switch (instruction) { + case "開團": { + message = await this.startBuying(event, msg, bot); + break; + } + + case "購買": { + message = await this.buying(event, msg, bot); + break; + } + + case "查詢": { + message = await this.searchBuying(event, msg[2], bot); + break; + } + + case "收單": { + message = await this.stopBuying(event, msg[2], bot); + break; + } + + default: + break; + } + return message; + } + + /** + * 開始團購 + * @param msg + */ + private static async startBuying(event: any, msg: any[], bot: line.Client): Promise { + const storename: string = msg[2]; + const query: string = String.Format("INSERT INTO `line_catan_groupbuy`.`groupbuydata` (`storename`) VALUES ('{0}');" + , storename); + const queryresp: any = await DBTools.Query(query); + + const message: line.Message = { + type: "text", + text: storename + " 已開團成功" + }; + return message; + } + + /** + * 購物 + * @param msg + */ + private static async buying(event: any, msg: any[], bot: line.Client): Promise { + const storename: string = msg[2]; + const food: string = msg[3]; + const count: number = +msg[4]; + let note: string = msg[5] ?? ""; + const userId: string = event.source.userId; + const userdata: line.Profile = await bot.getGroupMemberProfile(event.source.groupId, userId); + + const query: string = String.Format("SELECT * FROM `line_catan_groupbuy`.`groupbuydata` WHERE `storename` = '{0}' ORDER BY `storename` DESC LIMIT 1;", storename); + const queryresp: any[] = await DBTools.Query(query); + if (queryresp.length === 0) { + const message: line.Message = { + type: "text", + text: `錯誤` + }; + return message; + } + const querydata: any = queryresp[0]; + if (!querydata.isopen) { + const message: line.Message = { + type: "text", + text: `團購未開放` + }; + return message; + } + + let totalCount: number = count; + let data: any = querydata.data ? JSON.parse(querydata.data) : {}; + if (data[food]) { + const userbuydata: any = data[food][userId]; + if (userbuydata) { + totalCount = userbuydata.count = count; + if (note) { + userbuydata.note = note; + } else { + note = userbuydata.note; + } + } else { + const userbuydata: any = { + count: count, + note: note + }; + data[food][userId] = userbuydata; + } + } else { + const userbuydata: any = { + count: count, + note: note + }; + const adddata: Object = {}; + adddata[userId] = userbuydata; + data[food] = adddata; + } + const data_str: string = JSON.stringify(data); + const queryAddData: string = String.Format("UPDATE `line_catan_groupbuy`.`groupbuydata` SET `data` = '{0}' WHERE `id` = {1};" + , data_str, querydata.id); + await DBTools.Query(queryAddData); + let test: string = `${userdata.displayName} 已${count > 0 ? "增加" : "減少"}成功 ${querydata.storename}的${food} ${count}個, 總共${totalCount}個`; + if (note) { + test += ` 備註: ${note}`; + } + const message: line.Message = { + type: "text", + text: test + }; + return message; + } + + private static async searchBuying(event: any, storename: string, bot: line.Client): Promise { + let resp: line.Message | line.Message[] = { + type: "text", + text: "沒有資料" + }; + const query: string = String.Format("SELECT * FROM `line_catan_groupbuy`.`groupbuydata` WHERE `storename` = '{0}' ORDER BY `id` DESC LIMIT 1;", storename); + const queryresp: any[] = await DBTools.Query(query); + if (queryresp && queryresp.length > 0) { + resp = await this.getData_LineFlex(event, queryresp[0], bot); + } else { + const message: line.Message = { + type: "text", + text: `沒有符合的選項` + }; + return message; + } + return resp; + } + + private static async stopBuying(event: any, storename: string, bot: line.Client): Promise { + let resp: line.Message | line.Message[] = { + type: "text", + text: "沒有資料" + }; + const query: string = String.Format("SELECT * FROM `line_catan_groupbuy`.`groupbuydata` WHERE `storename` = '{0}' AND `isopen` = '1' ORDER BY `id` DESC LIMIT 1;", storename); + const queryresp: any[] = await DBTools.Query(query); + if (queryresp.length > 0) { + const query1: string = String.Format("UPDATE `line_catan_groupbuy`.`groupbuydata` SET `isopen` = 0 WHERE `id` = {0};", queryresp[0].id); + const queryresp1: any[] = await DBTools.Query(query1); + resp = await this.searchBuying(event, storename, bot); + } else { + const message: line.Message = { + type: "text", + text: `沒有符合的選項` + }; + return message; + } + return resp; + } + + /** + * 取得名單Line模板 + * @param storename + * @param matchs + */ + private static async getData_LineFlex(event: any, queryresp: any, bot: line.Client): Promise { + let data_LineFlex: string = queryresp.storename; + const data_Str: string = queryresp.data; + const names: string[] = []; + if (data_Str) { + const data: any = JSON.parse(data_Str); + for (let i: number = 0, data_key: string[] = Object.keys(data); i < data_key.length; i++) { + const food: string = data_key[i]; + const shopping: Object = data ? data[food] : {}; + data_LineFlex += `\n\n品項: ${food}`; + for (let j: number = 0, shopping_key: string[] = Object.keys(shopping); j < shopping_key.length; j++) { + const userId: string = shopping_key[j]; + const userbuydata: any = shopping[userId]; + const count: number = userbuydata.count; + const note: string = userbuydata.note; + if (count) { + let name: string = names[userId]; + if (!name) { + const userdata: line.Profile = await bot.getGroupMemberProfile(event.source.groupId, userId); + name = names[userId] = userdata?.displayName ?? userId; + } + + data_LineFlex += `\n${name} ${count}個` + (note ? `, 備註: ${note}` : ""); + } + } + } + } else { + data_LineFlex += `\nㄏ 沒人買`; + } + const messages: line.Message = { + type: "text", + text: data_LineFlex + }; + return messages; + } + + //#endregion +} \ No newline at end of file diff --git a/src/LineBotClass.ts b/src/LineBotClass.ts new file mode 100644 index 0000000..49e2747 --- /dev/null +++ b/src/LineBotClass.ts @@ -0,0 +1,122 @@ +import * as line from "@line/bot-sdk"; +import dateFormat from "dateformat"; +import express from "express"; +import fs from "fs"; +import { IncomingMessage, ServerResponse } from "http"; +import https from "https"; +import MemberJoinedClass from "./MemberJoinedClass"; +import MessageClass from "./MessageClass"; +import PostbackClass from "./PostbackClass"; + +/** + * LineBot + */ +export default class LineBotClass { + + //#region private + + private bot: line.Client = null; + + private message: MessageClass; + private postback: PostbackClass; + private memberJoined: MemberJoinedClass; + + //#endregion + + //#region Lifecycle + + /** + * + */ + constructor() { + //讀取憑證及金鑰 + const prikey: string = fs.readFileSync("./certificate/RSA-privkey.pem", "utf8"); + const cert: string = fs.readFileSync("./certificate/RSA-cert.pem", "utf8"); + const cafile: string = fs.readFileSync("./certificate/RSA-chain.pem", "utf-8"); + + //建立憑證及金鑰 + const credentials: Object = { + key: prikey, + cert: cert, + ca: cafile + }; + + // 用於辨識Line Channel的資訊 + const config: any = { + channelSecret: process.env.channelSecret, + channelAccessToken: process.env.channelAccessToken || "" + }; + this.bot = new line.Client(config); + this.message = new MessageClass(this.bot); + this.postback = new PostbackClass(this.bot); + this.memberJoined = new MemberJoinedClass(this.bot); + + + // Bot所監聽的webhook路徑與port + const path: string = process.env.URLPATH || "/"; + const port: number = +process.env.PORT || 3000; + // tslint:disable-next-line:typedef + const app = express(); + const httpsServer: https.Server = https.createServer(credentials, app); + app.post(path, line.middleware(config), (req, res) => { + Promise + .all(req.body.events.map(this.handleEvent.bind(this))) + .then((result) => res.json(result)); + }); + httpsServer.listen(port, () => { + let datetime: string = dateFormat(new Date(), "yyyy-mm-dd HH:MM:ss"); + console.log(`${datetime} listening on ${port}`); + console.log(`${datetime} [BOT已準備就緒]`); + }); + } + + //#endregion + + //#region Custom + + private async handleEvent(event: any): Promise { + try { + switch (event.type) { + case "message": { + this.message.Message(event); + break; + } + + case "postback": { + this.postback.Postback(event); + break; + } + + case "join": + case "leave": + case "follow": + case "unfollow": + case "memberJoin": + case "memberLeave": + case "accountLink": + case "fallback": + default: { + return Promise.resolve(null); + } + } + // if (event.type !== "message" || event.message.type !== "text") { + // return Promise.resolve(null); + // } + + // return this.bot.replyMessage(event.replyToken, { + // type: "text", + // text: event.message.text + // }); + } catch (error) { + let datetime: string = dateFormat(new Date(), "yyyy-mm-dd HH:MM:ss"); + const messages: line.Message = { + type: "text", + text: `錯誤` + }; + console.error(`${datetime} 錯誤:\n${JSON.stringify(event)}\n\n${error}`); + this.bot.replyMessage(event.replyToken, messages); + } + } + + //#endregion +} diff --git a/src/LineNotify.ts b/src/LineNotify.ts new file mode 100644 index 0000000..fb4ffe4 --- /dev/null +++ b/src/LineNotify.ts @@ -0,0 +1,37 @@ +/** + * LineNotify + */ +export default class LineNotify { + + //#region Custom + public static Send(message: string): void { + const data: string = `message=\n${message}`; + + const xhr: XMLHttpRequest = new XMLHttpRequest(); + + xhr.addEventListener("readystatechange", function (): void { + if (this.readyState === 4) { + // console.log(this.responseText); + } + }); + + xhr.open("POST", "https://notify-api.line.me/api/notify"); + xhr.setRequestHeader("Authorization", "Bearer Dkv8Yh1Li3XsKFqZkmFMNP5o0JDSvan7qfcDmSv9GJr"); + xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + + xhr.send(data); + } + + public static SendBadmintonNotify(): void { + const xhr: XMLHttpRequest = new XMLHttpRequest(); + xhr.addEventListener("readystatechange", function (): void { + if (this.readyState === 4) { + // console.log(this.responseText); + } + }); + xhr.open("POST", "http://jianmiau.tk:1880/BadmintonNotify"); + xhr.send(); + } + + //#endregion +} diff --git a/src/MemberJoinedClass.ts b/src/MemberJoinedClass.ts new file mode 100644 index 0000000..c6428ac --- /dev/null +++ b/src/MemberJoinedClass.ts @@ -0,0 +1,55 @@ +import * as line from "@line/bot-sdk"; +import LineNotify from "./LineNotify"; + +/** + * MemberJoined + */ +export default class MemberJoinedClass { + + //#region private + + private bot: line.Client = null; + + //#endregion + + //#region Lifecycle + + constructor(bot: line.Client) { + this.bot = bot; + } + + //#endregion + + //#region Custom + + public async MemberJoined(event: any): Promise { + switch (event.source.groupId) { + case process.env.toBadminton: { + this.badminton_MemberJoin(event); + break; + } + + default: + break; + } + } + + private async badminton_MemberJoin(event: any): Promise { + const members: any[] = event.joined.members; + for (let i: number = 0; i < members.length; i++) { + const userId: string = members[i].userId; + this.sendBadminton_Welcome(event, event.source.groupId, userId); + } + } + + private async sendBadminton_Welcome(event: any, groupId: string, userId: string): Promise { + const userdata: line.Profile = await this.bot.getGroupMemberProfile(groupId, userId); + if (userdata && userdata.displayName) { + const message: string = `歡迎尊貴的 ${userdata.displayName} 降臨羽球團`; + event.reply(message); + LineNotify.SendBadmintonNotify(); + } + } + + //#endregion +} diff --git a/src/MessageClass.ts b/src/MessageClass.ts new file mode 100644 index 0000000..f610e83 --- /dev/null +++ b/src/MessageClass.ts @@ -0,0 +1,87 @@ +import * as line from "@line/bot-sdk"; +import GroupBuy from "./GroupBuy"; + +/** + * Message + */ +export default class MessageClass { + + //#region private + + private bot: line.Client = null; + + //#endregion + + //#region Lifecycle + + constructor(bot: line.Client) { + this.bot = bot; + } + + //#endregion + + //#region Custom + + public async Message(event: any): Promise { + switch (event.message.type) { + case "text": { + return await this.Text(event); + } + + default: + break; + } + } + + public async Text(event: any): Promise { + switch (event.source.type) { + case "group": { + return await this.Group(event); + } + + default: + break; + } + } + + public async Group(event: any): Promise { + let groupId: string = event.source.groupId; + // 團購 特別功能 + if ([process.env.toGroupBuy].includes(groupId)) { + /** 訊息 */ + let msg: string[] = event.message.text.split(" "); + /** 指令 */ + let instruction: string = msg[0]; + switch (instruction) { + case "團購": { + const messages: line.Message | line.Message[] = await GroupBuy.Run(event, msg, this.bot); + if (messages) { + return this.bot.replyMessage(event.replyToken, messages); + } + } + + default: { + break; + } + } + } + } + + // Group(event) { + // switch (event.source.groupId) { + // case process.env.toYoutube: { + // let messagereplace = event.message.text; + // messagereplace.replace("【IFTTT】 \n", ""); + // let replyMsg = messagereplace; + // let res_toUniversity = this.bot.pushMessage(process.env.toUniversity, replyMsg); + // let res_toApex = this.bot.pushMessage(process.env.toApex, replyMsg); + // break; + // } + + // default: + // break; + // } + // } + + //#endregion +} diff --git a/src/OpenAI.ts b/src/OpenAI.ts new file mode 100644 index 0000000..75a57ee --- /dev/null +++ b/src/OpenAI.ts @@ -0,0 +1,35 @@ +import { Configuration, CreateCompletionResponseChoicesInner, OpenAIApi } from "openai"; + +/** + * OpenAI + */ +export default class OpenAI { + + //#region Custom + + public static async RunOpenAI(msg: string): Promise { + const configuration: Configuration = new Configuration({ + apiKey: process.env.OPENAI_API_KEY, + }); + const openai: OpenAIApi = new OpenAIApi(configuration); + // tslint:disable-next-line:typedef + const response = await openai.createCompletion({ + model: "text-davinci-003", + prompt: "你:" + msg, + temperature: 0.7, + max_tokens: 256, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + }); + let resptext: string = msg; + const choices: CreateCompletionResponseChoicesInner[] = response.data.choices; + for (let i: number = 0; i < choices.length; i++) { + const choice: CreateCompletionResponseChoicesInner = choices[i]; + resptext += choice.text; + } + return resptext; + } + + //#endregion +} diff --git a/src/PostbackClass.ts b/src/PostbackClass.ts new file mode 100644 index 0000000..e29d708 --- /dev/null +++ b/src/PostbackClass.ts @@ -0,0 +1,76 @@ +import * as line from "@line/bot-sdk"; +import GroupBuy from "./GroupBuy"; + +/** + * Postback + */ +export default class PostbackClass { + + //#region private + + private bot: line.Client = null; + + //#endregion + + //#region Lifecycle + + constructor(bot: line.Client) { + this.bot = bot; + } + + //#endregion + + //#region Custom + + public async Postback(event: any): Promise { + switch (event.source.type) { + case "group": { + return await this.Group(event); + } + + default: + break; + } + } + + public async Group(event: any): Promise { + let groupId: string = event.source.groupId; + // 團購 特別功能 + if ([process.env.toGroupBuy].includes(groupId)) { + /** 訊息 */ + let msg: string[] = event.postback.data.split(" "); + /** 指令 */ + let instruction: string = msg[0]; + switch (instruction) { + case "團購": { + const messages: line.Message | line.Message[] = await GroupBuy.Run(event, msg, this.bot); + if (messages) { + return this.bot.replyMessage(event.replyToken, messages); + } + } + + default: { + break; + } + } + } + } + + // Group(event) { + // switch (event.source.groupId) { + // case process.env.toYoutube: { + // let messagereplace = event.message.text; + // messagereplace.replace("【IFTTT】 \n", ""); + // let replyMsg = messagereplace; + // let res_toUniversity = this.bot.pushMessage(process.env.toUniversity, replyMsg); + // let res_toApex = this.bot.pushMessage(process.env.toApex, replyMsg); + // break; + // } + + // default: + // break; + // } + // } + + //#endregion +} diff --git a/src/Tools.ts b/src/Tools.ts new file mode 100644 index 0000000..ab7e94e --- /dev/null +++ b/src/Tools.ts @@ -0,0 +1,13 @@ +/** + * Tools + */ +export default class Tools { + + //#region Custom + + public static Sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + //#endregion +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..e77180d --- /dev/null +++ b/src/app.ts @@ -0,0 +1,16 @@ +// 背景執行 forever start -c ts-node -a -l line-bot-ts.log src/app.ts +// 重新背景執行 forever restart -a -l line-bot-ts.log src/app.ts +// 監聽檔案變化 "npm start" +// 連線Debug "npm run dev" + +import dayjs from "dayjs"; +import "dayjs/locale/zh-tw"; +import dotenv from "dotenv"; +import "./Engine/CCExtensions/ArrayExtension"; +import "./Engine/CCExtensions/NumberExtension"; +import "./Engine/String"; +import LineBotClass from "./LineBotClass"; + +dayjs.locale("zh-tw"); +dotenv.config(); +new LineBotClass(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4a4ae41 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es5", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "module": "commonjs", /* Specify what module code is generated. */ + "lib": [ + "es2015", + "es2017", + "dom" + ], + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + "strict": false, /* Enable all strict type-checking options. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "outDir": "dist" // 將編譯過後的js檔放到dist資料夾中 + } +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..b1671c0 --- /dev/null +++ b/tslint.json @@ -0,0 +1,107 @@ +{ + "defaultSeverity": "warning", + "rules": { + "ban": [ + true, + [ + "_", + "extend" + ], + [ + "_", + "isNull" + ], + [ + "_", + "isDefined" + ] + ], + "class-name": false, + "comment-format": [ + false, + "check-space" + ], + "curly": true, + "eofline": false, + "forin": false, + "indent": [ + true, + 4 + ], + "interface-name": [ + false, + "never-prefix" + ], + "jsdoc-format": false, + "label-position": true, + "max-line-length": [ + false, + 140 + ], + "no-arg": true, + "no-bitwise": false, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-variable": true, + "no-empty": true, + // "no-eval": true, + "no-string-literal": false, + "no-trailing-whitespace": true, + "no-unused-expression": false, + "no-unused-variable": true, + "no-use-before-declare": false, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "quotemark": [ + true, + "double" + ], + "radix": true, + "semicolon": [ + true, + "always" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef": [ + true, + "call-signature", + "parameter", + "property-declaration", + "variable-declaration" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace" + }, + { + "index-signature": "space" + } + ], + "variable-name": false, + "whitespace": [ + false, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +} \ No newline at end of file