first commit

main
Amir Hossein Moghiseh 2025-05-09 20:54:13 +03:30
commit cc006c8fbb
9 changed files with 3960 additions and 0 deletions

12
.editorconfig 100644
View File

@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space

172
.gitignore vendored 100644
View File

@ -0,0 +1,172 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars
.wrangler/

6
.prettierrc 100644
View File

@ -0,0 +1,6 @@
{
"printWidth": 140,
"singleQuote": true,
"semi": true,
"useTabs": true
}

3331
package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

18
package.json 100644
View File

@ -0,0 +1,18 @@
{
"name": "ancient-river-4662",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev"
},
"devDependencies": {
"esbuild": "^0.25.4",
"wrangler": "^4.14.3"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.804.0",
"@aws-sdk/node-http-handler": "^3.370.0"
}
}

13
src/package-lock.json generated 100644
View File

@ -0,0 +1,13 @@
{
"name": "ancient-river-4662",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ancient-river-4662",
"version": "1.0.0",
"license": "ISC"
}
}
}

5
src/package.json 100644
View File

@ -0,0 +1,5 @@
{
"name": "ancient-river-4662",
"version": "1.0.0",
"license": "ISC"
}

386
src/worker.js 100644
View File

@ -0,0 +1,386 @@
var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
// src/worker.js
var __defProp2 = Object.defineProperty;
var __name2 = /* @__PURE__ */ __name((target, value) => __defProp2(target, "name", { value, configurable: true }), "__name");
var worker_default = {
async fetch(request, env, ctx) {
try {
const url = new URL(request.url);
if (url.pathname.startsWith("/status/")) {
const eventId = url.pathname.split("/")[2];
return await handleStatusPage(eventId, env);
}
if (request.method !== "POST") return new Response("Only POST", { status: 405 });
const update = await request.json();
const message = update.message;
const callback = update.callback_query;
const inlineQuery = update.inline_query;
if (message?.text === "/start") {
await sendMessage(env.BOT_TOKEN, message.chat.id, "Welcome To Gebels Manager! Use /create_event to create an event or /list to see all events.");
return new Response("OK");
}
if (message?.text === "/list") {
const events = await listEvents(env.EVENTS);
if (!events.length) {
await sendMessage(env.BOT_TOKEN, message.chat.id, "\u{1F4ED} No events found.");
return new Response("OK");
}
for (const event of events) {
const text = `*${event.title}*
${event.description}
[\u2705 View Status](https://ancient-river-4662.siramirmoghi3.workers.dev/status/${event.id})`;
const markup = {
inline_keyboard: [[
{ text: "\u2705 Accept", callback_data: `accept_${event.id}` },
{ text: "\u274C Refuse", callback_data: `refuse_${event.id}` },
{ text: "🗑️ Delete", callback_data: `delete_${event.id}` },
]]
};
await sendPhoto(env.BOT_TOKEN, message.chat.id, event.image, text, "Markdown", markup);
}
return new Response("OK");
}
if (message?.text === "/cancel") {
await env.STATES.delete(message.from.id.toString());
await sendMessage(env.BOT_TOKEN, message.chat.id, "\u274C Event creation canceled.");
return new Response("OK");
}
if (message?.text === "/create_event") {
await env.STATES.put(message.from.id.toString(), JSON.stringify({ step: "title" }));
await sendMessage(env.BOT_TOKEN, message.chat.id, "Please enter the *title* of the event:", "Markdown");
return new Response("OK");
}
if (inlineQuery) {
const query = inlineQuery.query.trim().toLowerCase();
const allEvents = await listEvents(env.EVENTS);
const filtered = allEvents.filter((e) => e.title.toLowerCase().includes(query)).sort((a, b) => b.createdAt - a.createdAt).slice(0, 5);
const results = await Promise.all(filtered.map(async (e) => {
return {
type: "photo",
id: e.id,
photo_file_id: e.image,
// Using file_id for cached photo
caption: `*${e.title}*
${e.description}
[\u2705 View Status](https://ancient-river-4662.siramirmoghi3.workers.dev/status/${e.id})`,
parse_mode: "Markdown",
reply_markup: {
inline_keyboard: [[
{ text: "\u2705 Accept", callback_data: `accept_${e.id}` },
{ text: "\u274C Refuse", callback_data: `refuse_${e.id}` }
]]
}
};
}));
return await respondInlineQuery(env.BOT_TOKEN, inlineQuery.id, results);
}
if (callback) return await handleCallback(callback, env);
if (message) {
const userId = message.from.id.toString();
const stateRaw = await env.STATES.get(userId);
if (!stateRaw) return new Response("No state");
const state = JSON.parse(stateRaw);
const { step, data = {} } = state;
if (step === "title") {
data.title = message.text;
await env.STATES.put(userId, JSON.stringify({ step: "description", data }));
await sendMessage(env.BOT_TOKEN, message.chat.id, "Enter the *description*:", "Markdown");
} else if (step === "description") {
data.description = message.text;
await env.STATES.put(userId, JSON.stringify({ step: "image", data }));
await sendMessage(env.BOT_TOKEN, message.chat.id, "Please send an *image* for the event.", "Markdown");
} else if (step === "image" && message.photo) {
const fileId = message.photo.at(-1).file_id;
await env.STATES.put(userId, JSON.stringify({ step: 'limit', data: { ...data, fileId } }));
await sendMessage(env.BOT_TOKEN, message.chat.id, `Please enter a valid number for the acceptance limit or enter 0 for unlimited`, "Markdown");
} else if (step === 'limit') {
const limit = parseInt(message.text);
if (isNaN(limit)) {
await sendMessage(env.BOT_TOKEN, message.chat.id, 'Please enter a valid number for the acceptance limit or enter 0 for unlimited.');
} else {
data.limit = (limit === 0) ? null : limit; // 0 means no limit
const createdAt = new Date().toISOString(); // Track creation time
const eventId = crypto.randomUUID();
const eventData = {
...data,
limit: data.limit,
image: data.fileId, // Move fileId here in the event creation
id: eventId,
creatorId: message.from.id,
createdAt, // Add the creation timestamp
};
// Store event in the database
await env.EVENTS.put(eventId, JSON.stringify(eventData));
// Clear the state after event creation
await env.STATES.delete(userId);
await sendMessage(env.BOT_TOKEN, message.chat.id, `✅ Event created: *${data.title}*`, 'Markdown');
}
} else {
await sendMessage(env.BOT_TOKEN, message.chat.id, "Unexpected input. Try /create_event again.");
await env.STATES.delete(userId);
}
}
return new Response("OK");
} catch (err) {
console.error("Unhandled Exception:", {
message: err.message,
stack: err.stack,
name: err.name
});
return new Response("Internal Error", { status: 500 });
}
}
};
async function handleCallback(callback, env) {
const data = callback.data;
const [action, eventId] = data.split("_");
const user = callback.from.username || callback.from.first_name || "unknown";
const [rawAccepts, rawRefuses] = await Promise.all([
env.ACCEPTS.get(eventId),
env.REFUSES.get(eventId)
]);
const accepts = new Set(rawAccepts ? JSON.parse(rawAccepts) : []);
const refuses = new Set(rawRefuses ? JSON.parse(rawRefuses) : []);
accepts.delete(user);
refuses.delete(user);
const eventRaw = await env.EVENTS.get(eventId);
if (!eventRaw) return new Response("Event not found");
const event = JSON.parse(eventRaw);
if (action === "delete") {
// Only allow creator to delete the event
console.log(callback.from.id, event.creatorId);
if (callback.from.id !== event.creatorId) {
await sendMessage(env.BOT_TOKEN, callback.message.chat.id, "Only the event creator can delete this event.");
return new Response("Unauthorized");
}
// Delete event and all related data
await Promise.all([
env.EVENTS.delete(eventId),
env.ACCEPTS.delete(eventId),
env.REFUSES.delete(eventId)
]);
// Edit message or send update
await fetch(`https://api.telegram.org/bot${env.BOT_TOKEN}/editMessageText`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: callback.message.chat.id,
message_id: callback.message.message_id,
text: "🗑️ Event deleted by creator."
})
});
return new Response("Event deleted");
}
if (action === "accept") {
// Check if the acceptance limit has been reached
if (accepts.has(user)) {
await sendMessage(env.BOT_TOKEN, callback.message.chat.id, `You have already accepted the event *${event.title}*`, 'Markdown');
return new Response('OK');
}
if (event.limit !== 0 && event.limit !== null && accepts.size >= event.limit) {
await sendMessage(env.BOT_TOKEN, callback.message.chat.id, `Sorry, the event *${event.title}* has reached its acceptance limit.`, 'Markdown');
return new Response('OK');
}
accepts.add(user);
} else if (action === "refuse") {
refuses.add(user);
}
await Promise.all([
env.ACCEPTS.put(eventId, JSON.stringify([...accepts])),
env.REFUSES.put(eventId, JSON.stringify([...refuses]))
]);
const creatorId = event.creatorId;
const creatorMessage = `Your event *${event.title}* has received a new ${action === "accept" ? "acceptance ✅" : "refusal ❌"}.`;
await sendMessage(env.BOT_TOKEN, creatorId, creatorMessage, "Markdown");
// Wait for all notifications to be sent
if (!eventRaw) return new Response("Event not found");
await fetch(`https://api.telegram.org/bot${env.BOT_TOKEN}/answerCallbackQuery`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
callback_query_id: callback.id,
text: `You selected ${action === "accept" ? "\u2705 Accept" : "\u274C Refuse"}`
})
});
return new Response("OK");
}
__name(handleCallback, "handleCallback");
async function sendMessage(token, chat_id, text, parse_mode) {
return fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id, text, parse_mode })
});
}
__name(sendMessage, "sendMessage");
async function respondInlineQuery(token, queryId, results) {
return fetch(`https://api.telegram.org/bot${token}/answerInlineQuery`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inline_query_id: queryId, results, cache_time: 0 })
});
}
__name(respondInlineQuery, "respondInlineQuery");
async function listEvents(KV) {
const list = await KV.list();
return await Promise.all(
list.keys.map(async (k) => {
const raw = await KV.get(k.name);
return JSON.parse(raw);
})
);
}
__name(listEvents, "listEvents");
async function handleStatusPage(eventId, env) {
const [acceptsRaw, refusesRaw, eventRaw] = await Promise.all([
env.ACCEPTS.get(eventId),
env.REFUSES.get(eventId),
env.EVENTS.get(eventId)
]);
console.log("eventRaw", eventId, acceptsRaw, refusesRaw, eventRaw);
if (!eventRaw) return new Response("Event not found", { status: 404 });
const event = JSON.parse(eventRaw);
const accepts = acceptsRaw ? JSON.parse(acceptsRaw) : [];
const refuses = refusesRaw ? JSON.parse(refusesRaw) : [];
const fileResponse = await fetch(`https://api.telegram.org/bot${env.BOT_TOKEN}/getFile?file_id=${event.image}`);
const fileData = await fileResponse.json();
const fileUrl = `https://api.telegram.org/file/bot${env.BOT_TOKEN}/${fileData.result.file_path}`;
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gebels - ${event.title}</title>
<link href="https://fonts.googleapis.com/css2?family=UnifrakturCook:wght@700&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
background: linear-gradient(to bottom right, #2e003e, #120024);
font-family: 'UnifrakturCook', cursive;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.card {
background: rgba(255, 255, 255, 0.1);
border: 2px solid #b47cff;
border-radius: 16px;
padding: 20px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 20px rgba(180, 124, 255, 0.3);
backdrop-filter: blur(10px);
}
h1 {
font-size: 2em;
margin-bottom: 10px;
color: #ffe86a;
}
p {
font-size: 1.1em;
margin-bottom: 20px;
}
h2 {
font-size: 1.3em;
margin-top: 20px;
color: #94f0ff;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
li {
padding: 5px 10px;
margin: 5px 0;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 8px;
font-size: 1em;
}
a {
color: #ffe86a;
text-decoration: none;
}
a:hover {
text-decoration: underline;
color: #fff4c2;
}
</style>
</head>
<body>
<div class="card">
<h1>${event.title}</h1>
<img src="${fileUrl}" alt="${event.title}" style="width: 100%; border-radius: 8px; margin-bottom: 20px;" />
<p>${event.description}</p>
<h2>\u2705 Accepted</h2>
<ul>
${accepts.map((u) => `<li><a href="https://t.me/${u}" target="_blank">@${u}</a></li>`).join("")}
</ul>
<h2>\u274C Refused</h2>
<ul>
${refuses.map((u) => `<li><a href="https://t.me/${u}" target="_blank">@${u}</a></li>`).join("")}
</ul>
</div>
</body>
</html>
`;
return new Response(html, {
headers: { "Content-Type": "text/html" }
});
}
__name(handleStatusPage, "handleStatusPage");
async function sendPhoto(token, chat_id, photo, caption, parse_mode = "Markdown", reply_markup = null) {
const payload = {
chat_id,
photo,
caption,
parse_mode,
...reply_markup ? { reply_markup } : {}
};
return fetch(`https://api.telegram.org/bot${token}/sendPhoto`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
__name(sendPhoto, "sendPhoto");
__name2(handleCallback, "handleCallback");
export {
worker_default as default
};

17
wrangler.toml 100644
View File

@ -0,0 +1,17 @@
name = "ancient-river-4662"
main = "src/worker.js"
workers_dev = true
compatibility_date = "2025-05-08"
account_id = "6a8d4d305440e990b98e950f6453f950"
kv_namespaces = [
{ binding = "EVENTS", id = "0fc103ecca3b41d9af481cfb01ed9d52" },
{ binding = "ACCEPTS", id = "831bf99c66a44ec5a35e10c0dc4d086c" },
{ binding = "REFUSES", id = "36e31a9aebf04480a321234917a150a2" },
{ binding = "STATES", id = "af6bc31086de41f38055ced84b7c9606" }
]
[observability]
enabled = true