extensions

This commit is contained in:
Admin 2025-08-07 07:50:20 +07:00
parent 36234a7749
commit ab61e85eac
40 changed files with 5697 additions and 0 deletions

View File

@ -0,0 +1,3 @@
VITE_API_URL=https://notable-recently-seagull.ngrok-free.app/api/v1/
VITE_WS_URL=wss://notable-recently-seagull.ngrok-free.app
VITE_API_TYPE_URL=MyCoolApp

24
composer-bot-extensions/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,19 @@
function rightClickElementById(id) {
const element = document.getElementById(id);
if (!element) {
console.error("Element not found:", id);
return;
}
const event = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
view: window,
button: 2, // Chuột phải
buttons: 2,
clientX: element.getBoundingClientRect().left,
clientY: element.getBoundingClientRect().top
});
element.dispatchEvent(event);
}

View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

View File

@ -0,0 +1,38 @@
{
"manifest_version": 3,
"name": "Composer Bot",
"version": "1.0",
"description": "Composer Bot",
"action": {
"default_icon": {
"16": "icons/16.png",
"32": "icons/32.png",
"128": "icons/128.png"
}
},
"permissions": ["storage", "tabs", "alarms"],
"host_permissions": ["https://teams.live.com/*"],
"content_scripts": [
{
"matches": ["https://teams.live.com/*"],
"js": ["content.js"],
"run_at": "document_idle",
"type": "module"
}
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icons/16.png",
"32": "icons/32.png",
"128": "icons/128.png"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4379
composer-bot-extensions/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
{
"name": "re-make-bid-extension",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"dev:build": "vite build --watch"
},
"dependencies": {
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"next-themes": "^0.4.6",
"p-queue": "^8.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/chrome": "^0.1.0",
"@types/node": "^24.0.13",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4",
"vite-plugin-static-copy": "^3.1.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

View File

@ -0,0 +1,38 @@
{
"manifest_version": 3,
"name": "Composer Bot",
"version": "1.0",
"description": "Composer Bot",
"action": {
"default_icon": {
"16": "icons/16.png",
"32": "icons/32.png",
"128": "icons/128.png"
}
},
"permissions": ["storage", "tabs", "alarms"],
"host_permissions": ["https://teams.live.com/*"],
"content_scripts": [
{
"matches": ["https://teams.live.com/*"],
"js": ["content.js"],
"run_at": "document_idle",
"type": "module"
}
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icons/16.png",
"32": "icons/32.png",
"128": "icons/128.png"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,27 @@
import axios from "@/lib/axios";
class MessageApiService {
async sendSingleMessage(message: IMessage) {
try {
const { data } = await axios.post("/messages", message);
console.log("[NestJS] Response (single):", data);
return data;
} catch (err) {
console.error("[NestJS] Error (single):", err);
throw err;
}
}
async sendBulkMessages(messages: IMessage[]) {
try {
const { data } = await axios.post("/messages/bulk", { data: messages });
console.log("[NestJS] Response (bulk):", data);
return data;
} catch (err) {
console.error("[NestJS] Error (bulk):", err);
throw err;
}
}
}
export const messageApi = new MessageApiService();

View File

@ -0,0 +1,83 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { io, Socket } from "socket.io-client";
const EVENTS = {
GET_CONVERSATIONS: "messages.get-conversations",
GET_CONVERSATION: "messages.get-conversation",
RECEIVE_CONVERSATIONS: "messages.receive-conversations",
RECEIVE_CONVERSATION: "messages.receive-conversation",
SEND_MESSAGE: "messages.send-messsage",
REPLY_MESSAGE: "messages.reply-messsage",
};
let socket: Socket | null = null;
let ports: chrome.runtime.Port[] = [];
function initSocket() {
if (!socket) {
socket = io(`${import.meta.env.VITE_WS_URL}`, {
transports: ["websocket"],
// path: "/socket.io", // đảm bảo path đúng
});
// Kết nối port từ content script
chrome.runtime.onConnect.addListener((port) => {
if (port.name === "message") {
ports.push(port);
port.postMessage({ type: "status", msg: "Connected to background" });
port.onDisconnect.addListener(() => {
ports = ports.filter((p) => p !== port);
});
}
port.onMessage.addListener((msg: IMsg<any>) => {
console.log(`[${msg.event}] Received from content:`, msg);
if (msg.type !== "socket-response") return;
socket?.emit(msg.event, msg?.data);
});
});
// Socket.IO events
socket.on("connect", () => {
console.log("✅ Socket.IO connected");
broadcastToPorts({
type: "socket",
event: "connect",
msg: "Socket.IO connected",
});
});
// Listent events
const eventsToListen = [
EVENTS.GET_CONVERSATIONS,
EVENTS.GET_CONVERSATION,
EVENTS.SEND_MESSAGE,
EVENTS.REPLY_MESSAGE,
];
eventsToListen.forEach((event) => {
socket?.on(event, (data: any) => {
broadcastToPorts({
type: "socket",
event,
data,
});
});
});
}
}
function broadcastToPorts(message: any) {
ports.forEach((port) => {
try {
port.postMessage(message);
} catch (err) {
console.warn("❌ Failed to send message:", err);
}
});
}
initSocket();

View File

@ -0,0 +1,37 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { EVENTS } from "./lib/event";
import { ContentService } from "./services/content.service";
const port = chrome.runtime.connect({ name: "message" });
const contentService = new ContentService(port);
console.log({ a: import.meta.env.VITE_API_URL });
port.onMessage.addListener((msg: IMsg<any>) => {
console.log({ msg });
if (msg.type !== "socket") return;
switch (msg.event) {
case EVENTS.GET_CONVERSATIONS: {
contentService.getConversations(msg);
break;
}
case EVENTS.GET_CONVERSATION: {
contentService.getConversation(msg);
break;
}
case EVENTS.SEND_MESSAGE: {
contentService.sendMessage(msg);
break;
}
case EVENTS.REPLY_MESSAGE: {
contentService.replyMessage(msg);
break;
}
}
});

View File

@ -0,0 +1,161 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
export const EXTENTION_ROOT_ID = "bid-extensions";
export function extractModelId(url: string) {
switch (extractDomain(url)) {
case webs.grays: {
const match = url.match(/\/lot\/([\d-]+)\//);
return match ? match[1] : null;
}
case webs.langtons: {
const match = url.match(/auc-var-\d+/);
return match?.[0] || null;
}
case webs.lawsons: {
const match = url.split("_");
return match ? match[1] : null;
}
case webs.pickles: {
const model = url.split("/").pop();
return model ? model : null;
}
case webs.allbids: {
// eslint-disable-next-line no-useless-escape
const match = url.match(/-(\d+)(?:[\?#]|$)/);
return match ? match[1] : null;
}
}
}
export function extractDomain(url: string) {
try {
const parsedUrl = new URL(url);
return parsedUrl.origin;
} catch (error: any) {
return null;
}
}
export const webs = {
grays: "https://www.grays.com",
langtons: "https://www.langtons.com.au",
lawsons: "https://www.lawsons.com.au",
pickles: "https://www.pickles.com.au",
allbids: "https://www.allbids.com.au",
};
export const getMode = (data: { metadata: any[] }) => {
return (
data.metadata.find((item) => item.key_name === "mode_key")?.value || "live"
);
};
export const getEarlyTrackingSeconds = (
data: { metadata: any; web_bid?: any },
outsiteMode = null
) => {
const mode = outsiteMode ? outsiteMode : getMode(data);
return (
data.metadata.find(
(item: { key_name: string }) =>
item.key_name === `early_tracking_seconds_${mode}`
)?.value || data.web_bid.early_tracking_seconds
);
};
export const getArrivalOffsetSeconds = (
data: { metadata: any; web_bid?: any },
outsiteMode = null
) => {
const mode = outsiteMode ? outsiteMode : getMode(data);
return (
data.metadata.find(
(item: { key_name: string }) =>
item.key_name === `arrival_offset_seconds_${mode}`
)?.value || data.web_bid.arrival_offset_seconds
);
};
export function removeFalsyValues<T extends Record<string, any>>(
obj: T,
excludeKeys: (keyof T)[] = []
): Partial<T> {
return Object.entries(obj).reduce((acc, [key, value]) => {
if (value || excludeKeys.includes(key as keyof T)) {
acc[key as keyof T] = value;
}
return acc;
}, {} as Partial<T>);
}
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function getSecondsFromNow(datetime: {
date: Date | undefined;
time: string;
}): number | null {
if (!datetime.date) {
return null; // Trả về null nếu date không được chọn
}
// Tách giờ, phút, giây từ time string (HH:mm:ss)
const [hours, minutes, seconds] = datetime.time.split(":").map(Number);
// Tạo Date object mới từ date và time
const targetDate = new Date(datetime.date);
targetDate.setHours(hours, minutes, seconds, 0);
// Lấy thời điểm hiện tại
const now = new Date();
// Tính khoảng cách thời gian (mili giây)
const diffInMs = targetDate.getTime() - now.getTime();
// Chuyển sang giây (làm tròn xuống)
const diffInSeconds = Math.floor(diffInMs / 1000);
return diffInSeconds;
}
export function formatTimeFromMinutes(minutes: number): string {
// Tính ngày, giờ, phút từ số phút
const days = Math.floor(minutes / (60 * 24));
const hours = Math.floor((minutes % (60 * 24)) / 60);
const mins = minutes % 60;
let result = "";
if (days > 0) result += `${days} ${days > 1 ? "days" : "day"} `;
if (hours > 0) result += `${hours} ${hours > 1 ? "hours" : "hour"} `;
if (mins > 0 || result === "") result += `${mins} minutes`;
return result.trim();
}
export function getDatetimeFromSeconds(seconds: number): {
date: Date | undefined;
time: string;
} {
// Lấy thời điểm hiện tại
const now = new Date();
// Tính thời điểm tương lai bằng cách cộng số giây vào hiện tại
const targetDate = new Date(now.getTime() + seconds * 1000);
// Lấy giờ, phút, giây và định dạng thành chuỗi "HH:mm:ss"
const hours = String(targetDate.getHours()).padStart(2, "0");
const minutes = String(targetDate.getMinutes()).padStart(2, "0");
const secondsStr = String(targetDate.getSeconds()).padStart(2, "0");
const time = `${hours}:${minutes}:${secondsStr}`;
return {
date: targetDate,
time,
};
}

View File

@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const saveKey = (payload: any) => {
window.postMessage(
{
direction: "to-content",
type: "SAVE_KEY",
payload: payload,
},
"*"
);
};
export const getKey = () => {
window.postMessage(
{
direction: "to-content",
type: "GET_KEY",
},
"*"
);
};

View File

@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const saveStateLogin = (payload: any) => {
window.postMessage(
{
direction: "to-content",
type: "SAVE_STATE_LOGIN",
payload: payload,
},
"*"
);
};
export const getStateLogin = () => {
window.postMessage(
{
direction: "to-content",
type: "GET_STATE_LOGIN",
},
"*"
);
};

View File

@ -0,0 +1,9 @@
// import { TeamsChatService } from "./services/teams-chat.service";
// const service = new TeamsChatService();
// service.start(); // Mỗi 10 giây sẽ quét tin nhắn mới
// setTimeout(async () => {
// await service.scrollToBottomByXPath.bind(service)();
// await service.getConversationsInfo.bind(service)();
// }, 5000);

View File

@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface IMessage {
name?: string;
message?: string;
time: number;
room_id?: string;
room_name?: string;
date_time?: number;
}
interface IInputGeo {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
}
type ChatItemType = "group" | "personal" | null;
interface ChatItem {
id: string | null;
name: string | null;
type: ChatItemType;
}
interface IMsg<T> {
type: string;
event: string;
data: T;
}

View File

@ -0,0 +1,11 @@
import ax from "axios";
const axios = ax.create({
// Dev
baseURL: import.meta.env.VITE_API_URL,
headers: {
"Content-Type": "application/json",
},
});
export default axios;

View File

@ -0,0 +1,8 @@
export const EVENTS = {
GET_CONVERSATIONS: "messages.get-conversations",
GET_CONVERSATION: "messages.get-conversation",
RECEIVE_CONVERSATIONS: "messages.receive-conversations",
RECEIVE_CONVERSATION: "messages.receive-conversation",
SEND_MESSAGE: "messages.send-messsage",
REPLY_MESSAGE: "messages.reply-messsage",
};

View File

@ -0,0 +1,3 @@
import PQueue from "p-queue";
// Khởi tạo queue xử lý tuần tự
export const queue = new PQueue({ concurrency: 1 });

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,273 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { queue } from "@/lib/queue";
import { TeamsChatService } from "./teams-chat.service";
import { EVENTS } from "@/lib/event";
import { typeingService } from "./typing.service";
import { delay } from "@/features/app";
export class ContentService {
service: TeamsChatService;
port;
constructor(port: any) {
this.service = new TeamsChatService();
this.port = port;
}
private _clickToConversation(id: string) {
const el = document.getElementById(`chat-list-item_${id}`);
if (el) {
el.scrollIntoView({
behavior: "smooth", // hoặc "auto"
block: "center", // cuộn sao cho phần tử nằm giữa view
});
// Đợi 1 chút cho scroll xong (nếu cần)
setTimeout(() => el.click(), 200); // delay 200ms là đủ mượt
}
return el?.click();
}
private async _waitToloadMessages(stableTime = 300): Promise<any> {
return new Promise((resolve) => {
const chatPaneList = document.getElementById("chat-pane-list");
if (!chatPaneList) {
throw new Error("#chat-pane-list not found");
}
let timeout: NodeJS.Timeout | null = null;
let lastChildren: HTMLElement[] = [];
const observer = new MutationObserver(() => {
// Reset lại mỗi khi có thay đổi
if (timeout) clearTimeout(timeout);
// Cập nhật danh sách item mới nhất
lastChildren = Array.from(chatPaneList.children) as HTMLElement[];
timeout = setTimeout(() => {
observer.disconnect(); // Khi không có thay đổi trong khoảng stableTime
resolve(lastChildren);
}, stableTime);
});
// Quan sát cả subtree nếu có nested thay đổi
observer.observe(chatPaneList, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
});
}
private _getTypeGeo() {
const el = document.querySelector(".ck-placeholder");
if (el) {
const rect = el.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
};
}
return null;
}
private async _rightClickMessage(date_time: number) {
const xpath = this.service.elTags.container_chat;
const itemId = `content-${date_time}`;
const findElementByXPath = (xpath: string): HTMLElement | null => {
const result = document.evaluate(
xpath,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
return result.singleNodeValue as HTMLElement | null;
};
const maxScrollAttempts = 30;
const scrollStep = 200;
let attempts = 0;
let wrapper = findElementByXPath(xpath);
if (!wrapper) {
console.error("Wrapper not found:", xpath);
return;
}
let element = wrapper.querySelector<HTMLElement>(`#${itemId}`);
while (!element && attempts < maxScrollAttempts) {
const scrollContainer = wrapper;
const prevScrollTop = scrollContainer.scrollTop;
scrollContainer.scrollTop = Math.max(
0,
scrollContainer.scrollTop - scrollStep
);
await delay(200); // Đợi scroll và DOM render lại
wrapper = findElementByXPath(xpath);
if (!wrapper) break;
element = wrapper.querySelector<HTMLElement>(`#${itemId}`);
if (scrollContainer.scrollTop === prevScrollTop) break;
attempts++;
}
if (!element) {
console.error("Không tìm thấy phần tử:", itemId);
return;
}
// Scroll phần tử vào giữa màn hình
element.scrollIntoView({ behavior: "auto", block: "center" });
await delay(100); // Đợi scroll
const rect = element.getBoundingClientRect();
const event = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
view: window,
button: 2,
buttons: 2,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
});
element.dispatchEvent(event);
}
private _clickIfExists(xpath: string): Promise<boolean> {
return new Promise((resolve) => {
const result = document.evaluate(
xpath,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
const element = result.singleNodeValue as HTMLElement | null;
if (element) {
element.scrollIntoView({ behavior: "auto", block: "center" });
setTimeout(() => {
element.click();
resolve(true);
}, 100); // Đợi scroll một chút rồi click
} else {
console.warn("Không tìm thấy phần tử:", xpath);
resolve(false);
}
});
}
async getConversations(_: IMsg<any>) {
queue.add(async () => {
console.log("[Queue] Handling GET_CONVERSATIONS");
const data = await this.service.handleGetConversations();
// Gửi dữ liệu ngược về background
this.port.postMessage({
type: "socket-response",
event: EVENTS.RECEIVE_CONVERSATIONS,
data,
} as IMsg<any>);
});
}
async getConversation(msg: IMsg<{ id: string }>) {
queue.add(async () => {
console.log("[Queue] Handling GET_CONVERSATION");
if (!msg.data?.id) return;
const { room_id } = this.service.getCurrentRoomInfo();
// Nếu đang active room thì không cần chờ load
if (room_id != msg.data.id) {
this._clickToConversation(msg.data.id);
await this._waitToloadMessages();
}
const data = this.service.extractAllMessages();
this.port.postMessage({
type: "socket-response",
event: EVENTS.RECEIVE_CONVERSATION,
data,
} as IMsg<any>);
});
}
async sendMessage({
data: { conversation_id, message },
}: IMsg<{ conversation_id: string; message: string }>) {
queue.add(async () => {
console.log("[Queue] Handling SEND_MESSAGE");
const { room_id } = this.service.getCurrentRoomInfo();
// Nếu đang active room thì không cần chờ load
if (room_id != conversation_id) {
this._clickToConversation(conversation_id);
}
await delay(200);
await typeingService.send(message);
});
}
async replyMessage({
data: { conversation_id, message, time },
}: IMsg<{ conversation_id: string; message: string; time: number }>) {
queue.add(async () => {
console.log("[Queue] Handling REPLY_MESSAGE");
const { room_id } = this.service.getCurrentRoomInfo();
// Nếu đang active room thì không cần chờ load
if (room_id != conversation_id) {
this._clickToConversation(conversation_id);
await this._waitToloadMessages();
}
this._clickIfExists(this.service.elTags.close_reply_btn);
await this._rightClickMessage(time);
const replyBtn: any = document.querySelector(
this.service.elTags.reply_btn
);
if (replyBtn) {
replyBtn.click();
}
await delay(200);
console.log({ message });
await typeingService.send(message);
});
}
}

View File

@ -0,0 +1,268 @@
import { messageApi } from "@/api/message-api.service";
export class TeamsChatService {
private readonly MY_NAME = "Apactech com";
private lastMessage?: IMessage;
private initialHistories: IMessage[] = [];
public elTags = {
container_scroll:
"/html/body/div[1]/div/div/div/div[5]/div[1]/div[1]/div[2]/div[1]/div[1]/div",
conatainer_conversations:
"/html/body/div[1]/div/div/div/div[5]/div[1]/div[1]/div[2]/div[1]/div[1]/div/div[1]",
container_chat:
"/html/body/div[1]/div/div/div/div[6]/div[4]/div/div[1]/div/div[1]/div/div/div/div[1]/div/div/div[4]",
root_id: '[aria-selected="true"] [id^="chat-list-item"]',
room_name: '[data-tid="chat-title"]',
close_reply_btn:
"/html/body/div[1]/div/div/div/div[6]/div[4]/div/div[1]/div/div[3]/div/div[3]/div/div[2]/div/div[2]/div/div/card/div/div/div[2]/div/div[1]/button",
reply_btn: '[aria-label="Reply"]',
};
public getCurrentRoomInfo(): { room_id?: string; room_name?: string } {
// const roomId = document
// .querySelector(this.elTags.root_id)
// ?.id?.split(":")[1];
const roomId = document
.querySelector(this.elTags.root_id)
?.id?.replace("chat-list-item_", "");
const roomName = (
document.querySelector(this.elTags.room_name) as HTMLElement
)?.innerText;
return { room_id: roomId, room_name: roomName };
}
private _getMessageByEl(el: HTMLElement | null): string {
if (!el) return "";
// Lấy text ban đầu (nếu có)
let message = el.innerText || "";
// Nếu có ảnh gửi kèm (ảnh chia sẻ), thì ưu tiên trả về ảnh
const sharedImage = el.querySelector(
"img[data-gallery-src]"
) as HTMLImageElement | null;
if (sharedImage) {
return sharedImage.getAttribute("data-gallery-src") || "";
}
// Tìm tất cả emoji theo itemtype
const emojiImgs = Array.from(
el.querySelectorAll("img[itemtype]")
) as HTMLImageElement[];
const emojiAlts = emojiImgs
.map((img) => img.getAttribute("alt") || "")
.filter(Boolean);
// Nối emoji vào cuối chuỗi gốc (hoặc có thể chèn theo vị trí nâng cao)
if (emojiAlts.length) {
message += emojiAlts.join("");
}
return message.trim();
}
public parseMessageElement(el: Element, isMine = false): IMessage | null {
const timestampEl = el.querySelector(
isMine ? ".fui-ChatMyMessage__timestamp" : ".fui-ChatMessage__timestamp"
) as HTMLElement | null;
const authorEl = el.querySelector(
isMine ? ".fui-ChatMyMessage__author" : ".fui-ChatMessage__author"
) as HTMLElement | null;
if (!timestampEl) return null;
const datetimeAttr = timestampEl.getAttribute("datetime");
if (!datetimeAttr) return null;
// const dateTime = new Date(datetimeAttr).getTime();
const dateTime = Number.isNaN(timestampEl.id.replace("timestamp-", ""))
? new Date(datetimeAttr).getTime()
: Number(timestampEl.id.replace("timestamp-", ""));
const contentEl = document.querySelector(
`#content-${dateTime}`
) as HTMLElement | null;
const { room_id, room_name } = this.getCurrentRoomInfo();
return {
name: authorEl?.innerText,
message: this._getMessageByEl(contentEl),
time: dateTime,
room_id,
room_name,
date_time: new Date(datetimeAttr).getTime(),
};
}
extractAllMessages(): IMessage[] {
const myMessages: IMessage[] = Array.from(
document.querySelectorAll(".fui-ChatMyMessage")
)
.map((el) => this.parseMessageElement(el, true))
.filter((msg): msg is IMessage => msg !== null);
const otherMessages: IMessage[] = Array.from(
document.querySelectorAll(".fui-ChatMessage")
)
.map((el) => this.parseMessageElement(el, false))
.filter((msg): msg is IMessage => msg !== null);
console.log({ myMessages, otherMessages });
return [...myMessages, ...otherMessages].sort((a, b) => a.time - b.time);
}
private handleNewMessage(message: IMessage) {
// Gửi tới một server AI để phản hồi (nếu cần)
// fetch('https://127.0.0.1:8443/reply', { ... })
console.log("%c[New incoming message]", "color: #007acc;", message);
}
private async detectNewMessages() {
const allMessages = this.extractAllMessages();
const lastIndex = allMessages.findIndex(
(msg) => msg.time === this.lastMessage?.time
);
const newMessages = allMessages.slice(lastIndex + 1);
if (newMessages.length === 0) {
console.log("[Monitor] No new messages...");
return;
}
const newMsg = newMessages[0];
if (newMsg.name === this.MY_NAME) {
console.log("[Monitor] My new message:", newMsg);
await messageApi.sendSingleMessage(newMsg);
} else {
console.log("[Monitor] New incoming message:", newMsg);
this.handleNewMessage(newMsg);
messageApi.sendSingleMessage(newMsg);
}
this.lastMessage = allMessages.pop();
}
public async start(interval = 10000) {
console.log("[Monitor] Starting...");
this.initialHistories = this.extractAllMessages();
this.lastMessage = this.initialHistories.pop();
await messageApi.sendBulkMessages(this.initialHistories);
setInterval(async () => await this.detectNewMessages(), interval);
}
private async _getConversationsInfo(
xpath: string = this.elTags.conatainer_conversations
): Promise<ChatItem[]> {
const result = document.evaluate(
xpath,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
).singleNodeValue as HTMLElement | null;
if (!result) {
console.log("Không tìm thấy phần tử theo XPath.");
return [];
}
// Lọc phần tử con có role="none"
const matchedChildren = Array.from(result.children).filter(
(child: Element) => {
return child.getAttribute("role") === "none";
}
);
const data: ChatItem[] = matchedChildren.map((child: Element): ChatItem => {
const id = child.id || null;
const titleId = `title-chat-list-item_${id}`;
const titleElement = document.getElementById(titleId);
const spanText = titleElement?.innerText || null;
let type: ChatItemType = null;
if (id?.includes("@thread.skype")) {
type = "group";
} else if (id?.includes("@oneToOne.skype")) {
type = "personal";
} else {
type = "group";
}
return {
id,
name: spanText,
type,
};
});
return data;
}
private async _scrollToBottomByXPath(
xpath: string = this.elTags.container_scroll,
options?: {
maxStableRounds?: number; // Số vòng scroll không thay đổi trước khi dừng
delay?: number; // Thời gian chờ giữa mỗi lần scroll (ms)
maxScrolls?: number; // Giới hạn số lần scroll tối đa
}
): Promise<void> {
const {
maxStableRounds = 5,
delay = 300,
maxScrolls = 100,
} = options || {};
const container = document.evaluate(
xpath,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
).singleNodeValue as HTMLElement | null;
if (!container) {
console.warn("❌ Không tìm thấy phần tử với XPath:", xpath);
return;
}
return new Promise<void>((resolve) => {
let lastHeight = -1;
let stableCount = 0;
let scrolls = 0;
const interval = setInterval(() => {
const currentHeight = container.scrollHeight;
container.scrollTop = currentHeight;
if (currentHeight === lastHeight) {
stableCount++;
} else {
stableCount = 0;
lastHeight = currentHeight;
}
scrolls++;
if (stableCount >= maxStableRounds || scrolls >= maxScrolls) {
clearInterval(interval);
resolve();
}
}, delay);
});
}
async handleGetConversations() {
await this._scrollToBottomByXPath();
return this._getConversationsInfo();
}
}

View File

@ -0,0 +1,32 @@
import axios, { type AxiosInstance } from "axios";
class TypingService {
axios: AxiosInstance | null = null;
constructor() {
this.axios = axios.create({
baseURL: import.meta.env.VITE_API_TYPE_URL,
headers: {
"Content-Type": "application/json",
},
});
}
async send(
message: string
): Promise<{ status: boolean; typed: string } | null> {
if (!this.axios) return null;
const { data } = await this.axios({
method: "POST",
url: "type",
data: {
message,
},
});
return data;
}
}
export const typeingService = new TypingService();

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,16 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"target": "es2020",
"module": "es2020",
"moduleResolution": "bundler", // hoc "node" nếu bn dùng tsc không bundler
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,25 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "composer-bot-extensions",
cssCodeSplit: true,
rollupOptions: {
input: {
content: "src/content.ts",
background: "src/background.ts",
},
output: {
entryFileNames: "[name].js",
},
},
emptyOutDir: false,
},
});