瀏覽代碼

init: MVP

kotoyuuko 2 周之前
父節點
當前提交
9f13a0688d

+ 2 - 1
.editorconfig

@@ -2,7 +2,8 @@
 root = true
 
 [*]
-indent_style = tab
+indent_style = space
+indent_size = 2
 end_of_line = lf
 charset = utf-8
 trim_trailing_whitespace = true

+ 88 - 2
package-lock.json

@@ -7,6 +7,9 @@
 		"": {
 			"name": "cosmoe-bot",
 			"version": "0.0.0",
+			"dependencies": {
+				"grammy": "^1.39.3"
+			},
 			"devDependencies": {
 				"@cloudflare/vitest-pool-workers": "^0.8.19",
 				"typescript": "^5.5.2",
@@ -1141,6 +1144,12 @@
 				"node": ">=18"
 			}
 		},
+		"node_modules/@grammyjs/types": {
+			"version": "3.23.0",
+			"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.23.0.tgz",
+			"integrity": "sha512-D3jQ4UWERPsyR3op/YFudMMIPNTU47vy7L51uO9/73tMELmjO/+LX5N36/Y0CG5IQfIsz43MxiHI5rgsK0/k+g==",
+			"license": "MIT"
+		},
 		"node_modules/@img/colour": {
 			"version": "1.0.0",
 			"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@@ -2200,6 +2209,18 @@
 				"url": "https://opencollective.com/vitest"
 			}
 		},
+		"node_modules/abort-controller": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+			"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+			"license": "MIT",
+			"dependencies": {
+				"event-target-shim": "^5.0.0"
+			},
+			"engines": {
+				"node": ">=6.5"
+			}
+		},
 		"node_modules/acorn": {
 			"version": "8.14.0",
 			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@@ -2357,7 +2378,6 @@
 			"version": "4.4.3",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
 			"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
-			"dev": true,
 			"license": "MIT",
 			"dependencies": {
 				"ms": "^2.1.3"
@@ -2474,6 +2494,15 @@
 				"@types/estree": "^1.0.0"
 			}
 		},
+		"node_modules/event-target-shim": {
+			"version": "5.0.1",
+			"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+			"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
+			}
+		},
 		"node_modules/exit-hook": {
 			"version": "2.2.1",
 			"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
@@ -2544,6 +2573,21 @@
 			"dev": true,
 			"license": "BSD-2-Clause"
 		},
+		"node_modules/grammy": {
+			"version": "1.39.3",
+			"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.39.3.tgz",
+			"integrity": "sha512-7arRRoOtOh9UwMwANZ475kJrWV6P3/EGNooeHlY0/SwZv4t3ZZ3Uiz9cAXK8Zg9xSdgmm8T21kx6n7SZaWvOcw==",
+			"license": "MIT",
+			"dependencies": {
+				"@grammyjs/types": "3.23.0",
+				"abort-controller": "^3.0.0",
+				"debug": "^4.4.3",
+				"node-fetch": "^2.7.0"
+			},
+			"engines": {
+				"node": "^12.20.0 || >=14.13.1"
+			}
+		},
 		"node_modules/is-arrayish": {
 			"version": "0.3.4",
 			"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
@@ -2639,7 +2683,6 @@
 			"version": "2.1.3",
 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
 			"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-			"dev": true,
 			"license": "MIT"
 		},
 		"node_modules/nanoid": {
@@ -2661,6 +2704,26 @@
 				"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
 			}
 		},
+		"node_modules/node-fetch": {
+			"version": "2.7.0",
+			"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+			"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+			"license": "MIT",
+			"dependencies": {
+				"whatwg-url": "^5.0.0"
+			},
+			"engines": {
+				"node": "4.x || >=6.0.0"
+			},
+			"peerDependencies": {
+				"encoding": "^0.1.0"
+			},
+			"peerDependenciesMeta": {
+				"encoding": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/ohash": {
 			"version": "2.0.11",
 			"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@@ -2979,6 +3042,12 @@
 				"node": ">=14.0.0"
 			}
 		},
+		"node_modules/tr46": {
+			"version": "0.0.3",
+			"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+			"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+			"license": "MIT"
+		},
 		"node_modules/tslib": {
 			"version": "2.8.1",
 			"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3202,6 +3271,22 @@
 				}
 			}
 		},
+		"node_modules/webidl-conversions": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+			"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+			"license": "BSD-2-Clause"
+		},
+		"node_modules/whatwg-url": {
+			"version": "5.0.0",
+			"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+			"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+			"license": "MIT",
+			"dependencies": {
+				"tr46": "~0.0.3",
+				"webidl-conversions": "^3.0.0"
+			}
+		},
 		"node_modules/why-is-node-running": {
 			"version": "2.3.0",
 			"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -4325,6 +4410,7 @@
 			"dev": true,
 			"hasInstallScript": true,
 			"license": "Apache-2.0",
+			"peer": true,
 			"bin": {
 				"workerd": "bin/workerd"
 			},

+ 4 - 1
package.json

@@ -14,5 +14,8 @@
 		"typescript": "^5.5.2",
 		"vitest": "~3.2.0",
 		"wrangler": "^4.59.2"
+	},
+	"dependencies": {
+		"grammy": "^1.39.3"
 	}
-}
+}

+ 503 - 0
src/client/cosmoe.ts

@@ -0,0 +1,503 @@
+/* eslint-disable */
+
+/**
+ * Cosmoe API Client
+ * API Documentation: https://cos.world/api/v1/api_docs.html
+ * Base URL: http://cos.moe/api/v1/
+ */
+
+interface ApiResponse<T = any> {
+  code: number;
+  msg?: string;
+  data: T;
+}
+
+interface TokenResponse {
+  user_id: number;
+  token: string;
+}
+
+interface Event {
+  id: string; // Changed from number to string based on API response
+  name: string;
+  description: string;
+  event_date: string; // Changed from 'date'
+  cover_image_url: string; // Changed from 'cover_image'
+  description_brief: string; // Changed from 'summary'
+}
+
+interface TimeSlot {
+  range: string; // e.g. "13:30-14:30"
+  price: string; // price as string
+  capacity: number;
+  remaining: number;
+}
+
+interface EventDetail {
+  id: number;
+  name: string;
+  description: string;
+  event_date: string; // Changed from 'date'
+  time_slots_with_prices: string; // Raw string format
+  cover_image_url: string; // Changed from 'cover_image'
+  detail_image_urls: string; // Changed from 'gallery'
+  is_listed: number;
+  login_required: number;
+  allow_coupons: number;
+  created_at: string;
+  slots: TimeSlot[];
+  gallery: string[]; // Still exists as array of URLs
+}
+
+interface UserInfo {
+  id: number;
+  username: string;
+  email: string;
+  user_identity: string;
+  role: string;
+  is_active: number;
+  created_at: string;
+}
+
+interface Statistics {
+  total: number;
+  completed: number;
+  pending: number;
+}
+
+interface Coupon {
+  id: number;
+  code: string;
+  type: string; // 'percentage' or 'fixed'
+  value: number;
+  valid_to: string;
+}
+
+interface UserProfile {
+  user_info: UserInfo;
+  statistics: Statistics;
+}
+
+interface BookingRequest {
+  event_id: number | string;
+  time_slot: string;
+  user_note?: string;
+  payment_order_id?: string;
+  coupon_code?: string;
+}
+
+// Keeping ProfileData for backward compatibility
+interface ProfileData {
+  user_info: UserInfo;
+  statistics: Statistics;
+}
+
+interface Booking {
+  id: number;
+  status: string;
+  booking_date: string;
+  time_slot: string;
+  final_price: string;
+  payment_order_id: string;
+  notes_by_user: string;
+  created_at: string;
+  event_name: string;
+}
+
+interface BookingRequest {
+  event_id: number | string; // Changed to accept both string and number based on API response
+  time_slot: string;
+  user_note?: string;
+}
+
+export class CosmoeClient {
+  private baseUrl = "https://cos.world/api/v1";
+  private userId?: number;
+  private token?: string;
+
+  /**
+   * Get authentication token
+   * @param username User's username
+   * @param password User's password
+   * @returns Token response with user_id and token
+   */
+  async getToken(username: string, password: string): Promise<ApiResponse<TokenResponse>> {
+    const formData = new FormData();
+    formData.append("username", username);
+    formData.append("password", password);
+
+    const response = await fetch(`${this.baseUrl}/CreatToken.php`, {
+      method: "POST",
+      body: formData,
+    });
+
+    const result: ApiResponse<TokenResponse> = await response.json();
+    if (result.code === 200 && result.data) {
+      this.userId = result.data.user_id;
+      this.token = result.data.token;
+    }
+    return result;
+  }
+
+  /**
+   * Set user credentials manually
+   * @param userId User ID
+   * @param token Authentication token
+   */
+  setCredentials(userId: number, token: string): void {
+    this.userId = userId;
+    this.token = token;
+  }
+  
+  /**
+   * Get current user credentials
+   * @returns Current userId and token if authenticated
+   */
+  getCredentials(): { userId: number, token: string } | null {
+    if (this.isAuthenticated()) {
+      return {
+        userId: this.userId!,
+        token: this.token!
+      };
+    }
+    return null;
+  }
+
+  /**
+   * Check if user is authenticated
+   */
+  isAuthenticated(): boolean {
+    return !!this.userId && !!this.token;
+  }
+
+  /**
+   * Get events list
+   * @returns List of events
+   */
+  async getEvents(): Promise<ApiResponse<Event[]>> {
+    const params = new URLSearchParams({
+      action: "get_events",
+    });
+
+    const response = await fetch(`${this.baseUrl}/api.php?${params}`);
+    return await response.json() as ApiResponse<Event[]>;
+  }
+
+  /**
+   * Get event details
+   * @param eventId Event ID to get details for
+   * @returns Event details
+   */
+  async getEventDetail(eventId: number | string): Promise<ApiResponse<EventDetail>> {
+    const params = new URLSearchParams({
+      action: "get_event_detail",
+      event_id: String(eventId),
+    });
+
+    const response = await fetch(`${this.baseUrl}/api.php?${params}`);
+    return await response.json() as ApiResponse<EventDetail>;
+  }
+
+  /**
+   * Get user profile and statistics
+   * Requires authentication
+   * @returns User profile and statistics
+   */
+  async getProfile(): Promise<ApiResponse<ProfileData>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const params = new URLSearchParams({
+      action: "get_profile",
+      user_id: this.userId!.toString(),
+      token: this.token!,
+    });
+
+    const response = await fetch(`${this.baseUrl}/api.php?${params}`);
+    return await response.json() as ApiResponse<ProfileData>;
+  }
+
+  /**
+   * Get user's booking history
+   * Requires authentication
+   * @returns User's booking history
+   */
+  async getMyBookings(): Promise<ApiResponse<Booking[]>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const params = new URLSearchParams({
+      action: "get_my_bookings",
+      user_id: this.userId!.toString(),
+      token: this.token!,
+    });
+
+    const response = await fetch(`${this.baseUrl}/api.php?${params}`);
+    return await response.json() as ApiResponse<Booking[]>;
+  }
+
+  /**
+   * Book an event
+   * Requires authentication
+   * @param bookingRequest Booking details
+   * @returns Booking result
+   */
+  async bookEvent(bookingRequest: BookingRequest): Promise<ApiResponse<any>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const formData = new FormData();
+    formData.append("action", "book_event");
+    formData.append("user_id", this.userId!.toString());
+    formData.append("token", this.token!);
+    formData.append("event_id", String(bookingRequest.event_id));
+    formData.append("time_slot", bookingRequest.time_slot);
+    formData.append("coupon_code", bookingRequest.coupon_code || "");
+
+    if (bookingRequest.user_note) {
+      formData.append("user_note", bookingRequest.user_note);
+    }
+
+    const response = await fetch(`${this.baseUrl}/api.php`, {
+      method: "POST",
+      body: formData,
+    });
+
+    return await response.json() as ApiResponse<any>;
+  }
+
+  /**
+   * Change user password
+   * Requires authentication
+   * @param currentPassword Current password
+   * @param newPassword New password (at least 6 characters)
+   * @returns Password change result
+   */
+  async changePassword(currentPassword: string, newPassword: string): Promise<ApiResponse<any>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    if (newPassword.length < 6) {
+      throw new Error("New password must be at least 6 characters");
+    }
+
+    const formData = new FormData();
+    formData.append("action", "change_password");
+    formData.append("user_id", this.userId!.toString());
+    formData.append("token", this.token!);
+    formData.append("current_password", currentPassword);
+    formData.append("new_password", newPassword);
+
+    const response = await fetch(`${this.baseUrl}/api.php`, {
+      method: "POST",
+      body: formData,
+    });
+
+    return await response.json() as ApiResponse<any>;
+  }
+
+  /**
+   * Get user profile with updated structure
+   * Requires authentication
+   * @returns User profile and statistics
+   */
+  async getUserProfile(): Promise<ApiResponse<UserProfile>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const params = new URLSearchParams({
+      action: "get_profile",
+      user_id: this.userId!.toString(),
+      token: this.token!,
+    });
+
+    const response = await fetch(`${this.baseUrl}/api.php?${params}`);
+    return await response.json() as ApiResponse<UserProfile>;
+  }
+
+  /**
+   * Get available coupons for an event
+   * Requires authentication
+   * @param eventId Event ID to get coupons for
+   * @returns List of available coupons
+   */
+  async getAvailableCoupons(eventId: number | string): Promise<ApiResponse<Coupon[]>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const params = new URLSearchParams({
+      action: "get_available_coupons",
+      user_id: this.userId!.toString(),
+      token: this.token!,
+      event_id: String(eventId),
+    });
+
+    const response = await fetch(`${this.baseUrl}/api.php?${params}`);
+    return await response.json() as ApiResponse<Coupon[]>;
+  }
+
+  /**
+   * Update booking with payment order ID
+   * Requires authentication
+   * @param bookingId Booking ID to update
+   * @param paymentOrderId New payment order ID
+   * @returns Update result
+   */
+  async updatePaymentOrder(bookingId: number, paymentOrderId: string): Promise<ApiResponse<any>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const formData = new FormData();
+    formData.append("action", "update_payment_order");
+    formData.append("user_id", this.userId!.toString());
+    formData.append("token", this.token!);
+    formData.append("booking_id", bookingId.toString());
+    formData.append("payment_order_id", paymentOrderId);
+
+    const response = await fetch(`${this.baseUrl}/api.php`, {
+      method: "POST",
+      body: formData,
+    });
+
+    return await response.json() as ApiResponse<any>;
+  }
+
+  /**
+   * Update booking note
+   * Requires authentication
+   * @param bookingId Booking ID to update
+   * @param note New note
+   * @returns Update result
+   */
+  async updateBookingNote(bookingId: number, note: string): Promise<ApiResponse<any>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const formData = new FormData();
+    formData.append("action", "update_booking_note");
+    formData.append("user_id", this.userId!.toString());
+    formData.append("token", this.token!);
+    formData.append("booking_id", bookingId.toString());
+    formData.append("note", note);
+
+    const response = await fetch(`${this.baseUrl}/api.php`, {
+      method: "POST",
+      body: formData,
+    });
+
+    return await response.json() as ApiResponse<any>;
+  }
+
+  /**
+   * Cancel a booking
+   * Requires authentication
+   * @param bookingId Booking ID to cancel
+   * @returns Cancel result
+   */
+  async cancelBooking(bookingId: number): Promise<ApiResponse<any>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const formData = new FormData();
+    formData.append("action", "cancel_booking");
+    formData.append("user_id", this.userId!.toString());
+    formData.append("token", this.token!);
+    formData.append("booking_id", bookingId.toString());
+
+    const response = await fetch(`${this.baseUrl}/api.php`, {
+      method: "POST",
+      body: formData,
+    });
+
+    return await response.json() as ApiResponse<any>;
+  }
+
+  /**
+   * Self reschedule a booking
+   * Requires authentication
+   * @param bookingId Booking ID to reschedule
+   * @param newTimeSlot New time slot for the booking
+   * @returns Reschedule result
+   */
+  async selfReschedule(bookingId: number, newTimeSlot: string): Promise<ApiResponse<any>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const formData = new FormData();
+    formData.append("action", "self_reschedule");
+    formData.append("user_id", this.userId!.toString());
+    formData.append("token", this.token!);
+    formData.append("booking_id", bookingId.toString());
+    formData.append("new_time_slot", newTimeSlot);
+
+    const response = await fetch(`${this.baseUrl}/api.php`, {
+      method: "POST",
+      body: formData,
+    });
+
+    return await response.json() as ApiResponse<any>;
+  }
+
+  /**
+   * Self transfer a booking to another user
+   * Requires authentication
+   * @param bookingId Booking ID to transfer
+   * @param recipientUsername Username of the recipient
+   * @returns Transfer result
+   */
+  async selfTransfer(bookingId: number, recipientUsername: string): Promise<ApiResponse<any>> {
+    if (!this.isAuthenticated()) {
+      throw new Error("Authentication required");
+    }
+
+    const formData = new FormData();
+    formData.append("action", "self_transfer");
+    formData.append("user_id", this.userId!.toString());
+    formData.append("token", this.token!);
+    formData.append("booking_id", bookingId.toString());
+    formData.append("recipient_username", recipientUsername);
+
+    const response = await fetch(`${this.baseUrl}/api.php`, {
+      method: "POST",
+      body: formData,
+    });
+
+    return await response.json() as ApiResponse<any>;
+  }
+
+  /**
+   * Register a new user
+   * @param key Permission key (contact admin for access)
+   * @param username Username (2-10 characters, alphanumeric and underscore only)
+   * @param email Email address
+   * @param password Password (at least 6 characters)
+   * @param userIdentity User identity (coser/photographer/other, default photographer)
+   * @returns Registration result
+   */
+  async register(key: string, username: string, email: string, password: string, userIdentity: string = "photographer"): Promise<ApiResponse<{ user_id: number, username: string }>> {
+    const formData = new FormData();
+    formData.append("action", "register");
+    formData.append("key", key);
+    formData.append("username", username);
+    formData.append("email", email);
+    formData.append("password", password);
+    formData.append("user_identity", userIdentity);
+
+    const response = await fetch(`${this.baseUrl}/api.php`, {
+      method: "POST",
+      body: formData,
+    });
+
+    return await response.json() as ApiResponse<{ user_id: number, username: string }>;
+  }
+}

+ 127 - 0
src/command/handlers/bookEvent.ts

@@ -0,0 +1,127 @@
+import { Context } from "grammy";
+import { CosmoeClient } from "../../client/cosmoe";
+
+export interface Env {
+  BOT_INFO: string;
+  BOT_TOKEN: string;
+  COSMOE_CREDENTIALS: KVNamespace;
+  COSMOE_STORAGE: KVNamespace;
+}
+
+export async function handleBookEvent(ctx: Context, env: Env): Promise<void> {
+  try {
+    if (!ctx.message || !ctx.message.text) {
+      return;
+    }
+    
+    const match = /^\/book_(\d+)_(\d+)$/.exec(ctx.message.text);
+    if (match && match[1] && match[2]) {
+      const eventId = match[1];
+      const slotIndex = parseInt(match[2]);
+      
+      // Get user credentials from KV storage
+      const telegramUserId = ctx.from?.id.toString();
+      if (!telegramUserId) {
+        await ctx.reply("Could not identify your Telegram user ID.");
+        return;
+      }
+      
+      const storedCredentials = await env.COSMOE_CREDENTIALS.get(telegramUserId);
+      if (!storedCredentials) {
+        await ctx.reply("You need to login first using /login username password before booking events.");
+        return;
+      }
+      
+      const credentials = JSON.parse(storedCredentials);
+      
+      // Initialize Cosmoe API client and set credentials
+      const cosmoeClient = new CosmoeClient();
+      cosmoeClient.setCredentials(credentials.user_id, credentials.token);
+      
+      // Fetch event details to get time slots
+      const eventResult = await cosmoeClient.getEventDetail(eventId);
+      if (eventResult.code !== 200) {
+        await ctx.reply(`Event not found: ${eventResult.msg || 'Unknown error'}`);
+        return;
+      }
+      
+      const event = eventResult.data;
+      
+      // Sort slots by range for consistent ordering
+      const sortedSlots = [...event.slots].sort((a, b) => a.range.localeCompare(b.range));
+      
+      if (slotIndex < 0 || slotIndex >= sortedSlots.length) {
+        await ctx.reply(`Invalid slot number. Valid range is 0-${sortedSlots.length - 1}.`);
+        return;
+      }
+      
+      const selectedSlot = sortedSlots[slotIndex];
+      
+      if (selectedSlot.remaining <= 0) {
+        await ctx.reply(`Sorry, the selected slot (${selectedSlot.range}) is fully booked. No spots left.`);
+        return;
+      }
+      
+      // Check for available coupons for this event
+      let couponCode: string | undefined;
+      try {
+        const couponsResult = await cosmoeClient.getAvailableCoupons(eventId);
+        if (couponsResult.code === 200 && couponsResult.data && couponsResult.data.length > 0) {
+          // Use the first available coupon (you could implement logic to select the best one)
+          const availableCoupon = couponsResult.data[0];
+          couponCode = availableCoupon.code;
+          console.log(`Using coupon ${couponCode} for event ${eventId}`);
+        }
+      } catch (error) {
+        console.warn('Failed to fetch coupons, proceeding without coupon:', error);
+      }
+      
+      // Attempt to book the event with coupon if available
+      const bookingRequest: any = {
+        event_id: eventId,
+        time_slot: selectedSlot.range,
+      };
+      
+      if (couponCode) {
+        bookingRequest.coupon_code = couponCode;
+      }
+      
+      const bookingResult = await cosmoeClient.bookEvent(bookingRequest);
+      
+      if (bookingResult.code === 200) {
+        // Extract final price and booking ID from the response data if available
+        const finalPrice = bookingResult.data?.final_price || selectedSlot.price;
+        const bookingId = bookingResult.data?.id;
+        let successMessage = `Successfully booked event ${event.name} for slot ${selectedSlot.range}!`;
+        successMessage += `\nFinal Price: ¥${finalPrice}`;
+        if (bookingId) {
+          successMessage += `\nBooking ID: ${bookingId}`;
+        }
+        if (couponCode) {
+          successMessage += ` \n\(Used coupon: ${couponCode}\)`;
+        }
+        await ctx.reply(successMessage);
+      } else {
+        let errorMessage = `Booking failed: ${bookingResult.msg || 'Unknown error'}`;
+        if (couponCode) {
+          // Retry without coupon in case the coupon caused the failure
+          const retryResult = await cosmoeClient.bookEvent({
+            event_id: eventId,
+            time_slot: selectedSlot.range,
+          });
+          
+          if (retryResult.code === 200) {
+            await ctx.reply(`Successfully booked event ${event.name} for slot ${selectedSlot.range}! \(Original attempt failed with coupon, succeeded without coupon\)`);
+          } else {
+            await ctx.reply(errorMessage);
+          }
+        } else {
+          await ctx.reply(errorMessage);
+        }
+      }
+    }
+  } catch (error) {
+    console.error("Error handling booking request:", error);
+    await ctx.reply("An error occurred while processing your booking request. Please try again.");
+  }
+}

+ 131 - 0
src/command/handlers/cancel.ts

@@ -0,0 +1,131 @@
+import { Context, InlineKeyboard } from "grammy";
+import { CosmoeClient } from "../../client/cosmoe";
+
+export interface Env {
+  BOT_INFO: string;
+  BOT_TOKEN: string;
+  COSMOE_CREDENTIALS: KVNamespace;
+  COSMOE_STORAGE: KVNamespace;
+}
+
+export async function handleCancelCommand(ctx: Context, env: Env): Promise<void> {
+  try {
+    if (!ctx.message || !ctx.message.text) {
+      return;
+    }
+    
+    const match = /^\/cancel_(\d+)$/.exec(ctx.message.text);
+    if (match && match[1]) {
+      const bookingId = parseInt(match[1]);
+      
+      // Get user credentials from KV storage
+      const telegramUserId = ctx.from?.id.toString();
+      if (!telegramUserId) {
+        await ctx.reply("Could not identify your Telegram user ID.");
+        return;
+      }
+      
+      const storedCredentials = await env.COSMOE_CREDENTIALS.get(telegramUserId);
+      if (!storedCredentials) {
+        await ctx.reply("You need to login first using /login username password before canceling bookings.");
+        return;
+      }
+      
+      const credentialsObj = JSON.parse(storedCredentials);
+      
+      // Initialize Cosmoe client and set stored credentials
+      const cosmoeClient = new CosmoeClient();
+      cosmoeClient.setCredentials(credentialsObj.user_id, credentialsObj.token);
+      
+      if (!cosmoeClient.isAuthenticated()) {
+        await ctx.reply("❌ Invalid credentials. Please try logging in again with /login.");
+        return;
+      }
+      
+      // Fetch booking details to confirm what will be canceled
+      const bookingsResult = await cosmoeClient.getMyBookings();
+      
+      if (bookingsResult.code !== 200 || !bookingsResult.data) {
+        await ctx.reply("❌ Could not retrieve booking details. Cannot proceed with cancellation.");
+        return;
+      }
+      
+      const booking = bookingsResult.data.find(b => b.id === bookingId);
+      
+      if (!booking) {
+        await ctx.reply(`❌ Booking ID ${bookingId} not found.`);
+        return;
+      }
+      
+      // Create confirmation keyboard
+      const confirmationKeyboard = new InlineKeyboard()
+        .text('✅ Confirm Cancellation', `confirm_cancel_${bookingId}`)
+        .row()
+        .text('❌ Cancel', 'cancel_action');
+      
+      // Send confirmation message with inline keyboard
+      await ctx.reply(
+        `Are you sure you want to cancel this booking?\n\n` +
+        `Event: ${booking.event_name}\n` +
+        `Date: ${booking.booking_date}\n` +
+        `Time: ${booking.time_slot}\n` +
+        `Status: ${booking.status}\n\n` +
+        `⚠️ This action cannot be undone.`,
+        {
+          reply_markup: confirmationKeyboard,
+        }
+      );
+    }
+  } catch (error) {
+    console.error("Error handling cancel request:", error);
+    await ctx.reply("An error occurred while preparing the cancellation request. Please try again.");
+  }
+}
+
+// Handler for the confirmation callback
+export async function handleCancelConfirmation(ctx: Context, env: Env, action: string): Promise<void> {
+  try {
+    const parts = action.split('_');
+    if (parts[0] === 'confirm' && parts[1] === 'cancel') {
+      const bookingId = parseInt(parts[2]);
+      
+      // Get user credentials from KV storage
+      const telegramUserId = ctx.from?.id.toString();
+      if (!telegramUserId) {
+        await ctx.editMessageText("Could not identify your Telegram user ID.");
+        return;
+      }
+      
+      const storedCredentials = await env.COSMOE_CREDENTIALS.get(telegramUserId);
+      if (!storedCredentials) {
+        await ctx.editMessageText("You need to login first using /login username password before canceling bookings.");
+        return;
+      }
+      
+      const credentialsObj = JSON.parse(storedCredentials);
+      
+      // Initialize Cosmoe client and set stored credentials
+      const cosmoeClient = new CosmoeClient();
+      cosmoeClient.setCredentials(credentialsObj.user_id, credentialsObj.token);
+      
+      if (!cosmoeClient.isAuthenticated()) {
+        await ctx.editMessageText("❌ Invalid credentials. Please try logging in again with /login.");
+        return;
+      }
+      
+      // Attempt to cancel the booking
+      const cancelResult = await cosmoeClient.cancelBooking(bookingId);
+      
+      if (cancelResult.code === 200) {
+        await ctx.editMessageText(`✅ Successfully canceled booking ID: ${bookingId}`);
+      } else {
+        await ctx.editMessageText(`❌ Failed to cancel booking: ${cancelResult.msg || 'Unknown error'}`);
+      }
+    } else if (action === 'cancel_action') {
+      await ctx.editMessageText('Cancellation has been canceled.');
+    }
+  } catch (error) {
+    console.error("Error handling cancel confirmation:", error);
+    await ctx.editMessageText("An error occurred while processing your cancellation. Please try again.");
+  }
+}

+ 61 - 0
src/command/handlers/eventDetails.ts

@@ -0,0 +1,61 @@
+import { Context } from "grammy";
+import { CosmoeClient } from "../../client/cosmoe";
+
+export async function handleEventDetails(ctx: Context): Promise<void> {
+  try {
+    if (!ctx.message || !ctx.message.text) {
+      return;
+    }
+    
+    const match = /^\/event_(.+)$/.exec(ctx.message.text);
+    if (match && match[1]) {
+      const cosmoeClient = new CosmoeClient();
+      const eventId = match[1];
+      const result = await cosmoeClient.getEventDetail(eventId);
+      
+      if (result.code === 200) {
+        const event = result.data;
+        let message = `*${event.name}*\n`;
+        message += `Date: ${event.event_date}\n\n`;
+        message += `*Time Slots:*\n`;
+        
+        if (event.slots && event.slots.length > 0) {
+          // Check if today is the event day or before the event day (Beijing time)
+          const eventDate = new Date(event.event_date);
+          const now = new Date();
+          
+          // Adjust to Beijing timezone (UTC+8)
+          const beijingOffset = 8 * 60; // 8 hours in minutes
+          const beijingTime = new Date(now.getTime() + (now.getTimezoneOffset() + beijingOffset) * 60000);
+          
+          // Reset time parts for comparison (compare dates only)
+          const todayDate = new Date(beijingTime.getFullYear(), beijingTime.getMonth(), beijingTime.getDate());
+          const eventDay = new Date(eventDate.getFullYear(), eventDate.getMonth(), eventDate.getDate());
+          
+          const isEventActive = todayDate <= eventDay;
+          
+          // Sort slots by range for consistent ordering
+          const sortedSlots = [...event.slots].sort((a, b) => a.range.localeCompare(b.range));
+          
+          sortedSlots.forEach((slot: any, index: number) => {
+            message += `• ${slot.range}: ¥${slot.price} (${slot.remaining}/${slot.capacity} spots left)\n`;
+            if (slot.remaining > 0 && isEventActive) {
+              message += `  • book: /book\\_${eventId}\\_${index}\n`;
+            }
+          });
+        } else {
+          message += "No time slots available.\n";
+        }
+        
+        message += `\nCover Image: ${event.cover_image_url}`;
+        
+        await ctx.reply(message, { parse_mode: "Markdown" });
+      } else {
+        await ctx.reply(`Event not found or error occurred: ${result.msg || 'Unknown error'}`);
+      }
+    }
+  } catch (error) {
+    console.error("Error fetching event details:", error);
+    await ctx.reply("Error fetching event details. Please try again later.");
+  }
+}

+ 27 - 0
src/command/handlers/events.ts

@@ -0,0 +1,27 @@
+import { Context } from "grammy";
+import { CosmoeClient } from "../../client/cosmoe";
+
+export async function handleEventsCommand(ctx: Context): Promise<void> {
+  try {
+    const cosmoeClient = new CosmoeClient();
+    const result = await cosmoeClient.getEvents();
+    if (result.code === 200 && result.data.length > 0) {
+      // Take only the latest 10 events
+      const latestEvents = result.data.slice(0, 10);
+      
+      // Build a single message with all events
+      let message = "*Latest Events:*\n\n";
+      
+      for (const event of latestEvents) {
+        message += `• (/event\\_${event.id}) ${event.name}\n`;
+      }
+      
+      await ctx.reply(message, { parse_mode: "Markdown" });
+    } else {
+      await ctx.reply("No events available at the moment.");
+    }
+  } catch (error) {
+    console.error("Error fetching events:", error);
+    await ctx.reply("Error fetching events. Please try again later.");
+  }
+}

+ 109 - 0
src/command/handlers/history.ts

@@ -0,0 +1,109 @@
+import { Context } from "grammy";
+import { CosmoeClient } from "../../client/cosmoe";
+
+export async function handleHistoryCommand(ctx: Context, env: any) {
+  try {
+    // Get user ID from context
+    const userId = ctx.from?.id;
+    if (!userId) {
+      await ctx.reply("❌ Unable to identify your account. Please try again.");
+      return;
+    }
+
+    // Retrieve stored credentials for this user
+    const credentials = await env.COSMOE_CREDENTIALS.get(userId.toString());
+    if (!credentials) {
+      await ctx.reply("⚠️ You need to log in first. Please use /login command.");
+      return;
+    }
+
+    const credentialsObj = JSON.parse(credentials);
+    
+    // Initialize Cosmoe client and set stored credentials
+    const cosmoeClient = new CosmoeClient();
+    cosmoeClient.setCredentials(credentialsObj.user_id, credentialsObj.token);
+    
+    if (!cosmoeClient.isAuthenticated()) {
+      await ctx.reply("❌ Invalid credentials. Please try logging in again with /login.");
+      return;
+    }
+
+    // Get booking history
+    const bookingsResult = await cosmoeClient.getMyBookings();
+    
+    if (bookingsResult.code !== 200 || !bookingsResult.data) {
+      await ctx.reply("📋 No booking history found or failed to retrieve history.");
+      return;
+    }
+
+    const bookings = bookingsResult.data;
+
+    if (bookings.length === 0) {
+      await ctx.reply("📋 Your booking history is empty.");
+      return;
+    }
+
+    // Format and send booking history
+    let historyMessage = `📖 *Your Booking History*\n\n`;
+    
+    // Sort bookings by booking_date in descending order (most recent first) and take only the latest 10
+    const sortedBookings = [...bookings]
+      .sort((a, b) => new Date(b.booking_date).getTime() - new Date(a.booking_date).getTime())
+      .slice(0, 10);
+
+    for (let i = 0; i < sortedBookings.length; i++) {
+      const booking = sortedBookings[i];
+          
+      historyMessage += `*${i + 1}. ${booking.event_name}*\n`;
+      historyMessage += `📅 Date: ${booking.booking_date}\n`;
+      historyMessage += `⏰ Time: ${booking.time_slot}\n`;
+      historyMessage += `💰 Price: ¥${booking.final_price}\n`;
+      historyMessage += `💳 Status: ${booking.status}\n`;
+      historyMessage += `🗓️ Created: ${booking.created_at}\n`;
+          
+      if (booking.notes_by_user) {
+        historyMessage += `📝 Note: ${booking.notes_by_user}\n`;
+      }
+          
+      // Add cancel link for bookings that are not completed and have a future booking date (Beijing time)
+      const now = new Date();
+      // Convert to Beijing time
+      const beijingOffset = 8 * 60 * 60 * 1000; // Beijing is UTC+8
+      const beijingTime = new Date(now.getTime() + beijingOffset);
+            
+      // Parse the booking date - it might be in YYYY-MM-DD format
+      let bookingDateTime: Date;
+      if (booking.booking_date.includes(' ')) {
+        // If it includes time, parse as full datetime
+        bookingDateTime = new Date(booking.booking_date);
+      } else {
+        // If only date is provided, create date at start of day
+        const [year, month, day] = booking.booking_date.split('-').map(Number);
+        bookingDateTime = new Date(year, month - 1, day, 0, 0, 0, 0); // month is 0-indexed
+      }
+            
+      // Convert booking date to Beijing time for comparison
+      const beijingBookingDate = new Date(bookingDateTime.getTime() + beijingOffset);
+            
+      if (booking.status !== '已完成' && booking.status !== '已取消' && beijingBookingDate >= beijingTime) {
+        historyMessage += `🔗 Cancel: /cancel\\_${booking.id}\n`;
+      }
+          
+      historyMessage += `💳 Order ID: ${booking.payment_order_id}\n`;
+      historyMessage += `\n`;
+    }
+
+    // Limit message length to avoid Telegram API limits
+    if (historyMessage.length > 4000) {
+      historyMessage = historyMessage.substring(0, 4000) + "\n...";
+    }
+
+    await ctx.reply(historyMessage, { 
+      parse_mode: 'Markdown',
+      disable_notification: true
+    });
+  } catch (error) {
+    console.error('Error in handleHistoryCommand:', error);
+    await ctx.reply("❌ An error occurred while retrieving your booking history. Please try again later.");
+  }
+}

+ 52 - 0
src/command/handlers/login.ts

@@ -0,0 +1,52 @@
+import { Context } from "grammy";
+import { CosmoeClient } from "../../client/cosmoe";
+
+export interface Env {
+  BOT_INFO: string;
+  BOT_TOKEN: string;
+  COSMOE_CREDENTIALS: KVNamespace;
+  COSMOE_STORAGE: KVNamespace;
+}
+
+export async function handleLoginCommand(ctx: Context, env: Env): Promise<void> {
+  try {
+    const args = ctx.match?.toString().split(' ') || [];
+    if (args.length < 2) {
+      await ctx.reply("Please provide your username and password. Usage: /login username password");
+      return;
+    }
+    
+    const username = args[0];
+    const password = args[1];
+    
+    // Initialize Cosmoe API client
+    const cosmoeClient = new CosmoeClient();
+    
+    // Attempt to authenticate with Cosmoe API
+    const authResult = await cosmoeClient.getToken(username, password);
+    
+    if (authResult.code === 200 && authResult.data && authResult.data.user_id && authResult.data.token) {
+      // Store credentials in KV namespace associated with this Telegram user
+      const telegramUserId = ctx.from?.id.toString();
+      if (!telegramUserId) {
+        await ctx.reply("Could not identify your Telegram user ID.");
+        return;
+      }
+      
+      const credentials = {
+        user_id: authResult.data.user_id,
+        token: authResult.data.token,
+        timestamp: Date.now()
+      };
+      
+      await env.COSMOE_CREDENTIALS.put(telegramUserId, JSON.stringify(credentials));
+      
+      await ctx.reply(`Successfully logged in as ${username}. Your credentials are now stored securely.`);
+    } else {
+      await ctx.reply(`Login failed: ${authResult.msg || 'Invalid credentials'}`);
+    }
+  } catch (error) {
+    console.error("Error during login:", error);
+    await ctx.reply("An error occurred during login. Please try again.");
+  }
+}

+ 6 - 0
src/command/handlers/start.ts

@@ -0,0 +1,6 @@
+import { Context } from "grammy";
+import { CosmoeClient } from "../../client/cosmoe";
+
+export async function handleStartCommand(ctx: Context): Promise<void> {
+  await ctx.reply("Welcome to CosMoe Bot! Available commands:\n/events - See latest events\n/login - Log in to your account\n/history - View your booking history\n/cancel_{id} - Cancel a booking (replace {id} with booking ID)");
+}

+ 56 - 0
src/command/index.ts

@@ -0,0 +1,56 @@
+import { Bot, Context } from "grammy";
+import { handleStartCommand } from "./handlers/start";
+import { handleLoginCommand } from "./handlers/login";
+import { handleEventsCommand } from "./handlers/events";
+import { handleEventDetails } from "./handlers/eventDetails";
+import { handleBookEvent } from "./handlers/bookEvent";
+import { handleHistoryCommand } from "./handlers/history";
+import { handleCancelCommand, handleCancelConfirmation } from "./handlers/cancel";
+
+export interface Env {
+  BOT_INFO: string;
+  BOT_TOKEN: string;
+  COSMOE_CREDENTIALS: KVNamespace;
+  COSMOE_STORAGE: KVNamespace;
+}
+
+export function setupCommands(bot: Bot, env: Env) {
+  bot.command("start", async (ctx: Context) => {
+    await handleStartCommand(ctx);
+  });
+
+  bot.command("login", async (ctx: Context) => {
+    await handleLoginCommand(ctx, env);
+  });
+
+  bot.command("events", async (ctx: Context) => {
+    await handleEventsCommand(ctx);
+  });
+
+  // Handle /event_123 format using hears
+  bot.hears(/^\/event_(.+)$/, async (ctx) => {
+    await handleEventDetails(ctx);
+  });
+
+  // Handle /book_{event_id}_{slot_id} format
+  bot.hears(/^\/book_(\d+)_(\d+)$/, async (ctx) => {
+    await handleBookEvent(ctx, env);
+  });
+
+  bot.command("history", async (ctx: Context) => {
+    await handleHistoryCommand(ctx, env);
+  });
+
+  // Handle /cancel_{booking_id} format
+  bot.hears(/^\/cancel_(\d+)$/, async (ctx) => {
+    await handleCancelCommand(ctx, env);
+  });
+
+  // Handle callback queries for cancel confirmation
+  bot.callbackQuery(/confirm_cancel_\d+|cancel_action/, async (ctx) => {
+    if (ctx.callbackQuery.data) {
+      await handleCancelConfirmation(ctx, env, ctx.callbackQuery.data);
+      await ctx.answerCallbackQuery();
+    }
+  });
+}

+ 97 - 0
src/example.ts

@@ -0,0 +1,97 @@
+/**
+ * Example usage of the CosmoeClient
+ * Demonstrates how to use all the API endpoints
+ */
+
+import { CosmoeClient } from './client/cosmoe';
+
+async function example() {
+  const client = new CosmoeClient();
+  
+  try {
+    // 1. Get authentication token
+    console.log('Getting authentication token...');
+    const tokenResult = await client.getToken('your_username', 'your_password');
+    if (tokenResult.code === 200) {
+      console.log('Successfully authenticated:', tokenResult.data);
+    } else {
+      console.error('Authentication failed:', tokenResult.msg);
+      return;
+    }
+    
+    // 2. Get events list
+    console.log('\nGetting events list...');
+    const eventsResult = await client.getEvents();
+    if (eventsResult.code === 200) {
+      console.log('Available events:', eventsResult.data);
+      
+      // Get details of the first event if any exist
+      if (eventsResult.data.length > 0) {
+        const firstEvent = eventsResult.data[0];
+        console.log(`\nGetting details for event: ${firstEvent.name} on ${firstEvent.event_date}`);
+        
+        const eventDetailResult = await client.getEventDetail(Number(firstEvent.id));
+        if (eventDetailResult.code === 200) {
+          console.log('Event details:', eventDetailResult.data);
+                  
+          // 3. Book an event (example with first available time slot)
+          const firstTimeSlot = eventDetailResult.data.slots[0];
+          if (firstTimeSlot && firstTimeSlot.remaining > 0) {
+            console.log(`\nBooking event ${firstEvent.id} at ${firstTimeSlot.range}...`);
+                      
+            const bookingResult = await client.bookEvent({
+              event_id: Number(firstEvent.id),
+              time_slot: firstTimeSlot.range,
+              user_note: 'Booking via CosmoeBot'
+            });
+            
+            if (bookingResult.code === 200) {
+              console.log('Booking successful:', bookingResult.msg);
+            } else {
+              console.error('Booking failed:', bookingResult.msg);
+            }
+          }
+        } else {
+          console.error('Failed to get event details:', eventDetailResult.msg);
+        }
+      }
+    } else {
+      console.error('Failed to get events:', eventsResult.msg);
+    }
+    
+    // 4. Get user profile and statistics
+    console.log('\nGetting user profile...');
+    const profileResult = await client.getProfile();
+    if (profileResult.code === 200) {
+      console.log('User profile:', profileResult.data);
+    } else {
+      console.error('Failed to get profile:', profileResult.msg);
+    }
+    
+    // 5. Get user's booking history
+    console.log('\nGetting booking history...');
+    const bookingsResult = await client.getMyBookings();
+    if (bookingsResult.code === 200) {
+      console.log('Booking history:', bookingsResult.data);
+    } else {
+      console.error('Failed to get booking history:', bookingsResult.msg);
+    }
+    
+    // 6. Change password example (uncomment to use)
+    /*
+    console.log('\nChanging password...');
+    const passwordChangeResult = await client.changePassword('current_password', 'new_password');
+    if (passwordChangeResult.code === 200) {
+      console.log('Password changed successfully:', passwordChangeResult.msg);
+    } else {
+      console.error('Password change failed:', passwordChangeResult.msg);
+    }
+    */
+    
+  } catch (error) {
+    console.error('Error occurred:', error);
+  }
+}
+
+// Run the example
+example().catch(console.error);

+ 42 - 16
src/index.ts

@@ -1,18 +1,44 @@
-/**
- * Welcome to Cloudflare Workers! This is your first worker.
- *
- * - Run `npm run dev` in your terminal to start a development server
- * - Open a browser tab at http://localhost:8787/ to see your worker in action
- * - Run `npm run deploy` to publish your worker
- *
- * Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the
- * `Env` object can be regenerated with `npm run cf-typegen`.
- *
- * Learn more at https://developers.cloudflare.com/workers/
- */
+import { Bot, Context, webhookCallback } from "grammy";
+import { setupCommands } from "./command";
+import { handleScheduledEvent } from "./scheduler";
+
+export interface Env {
+  BOT_INFO: string;
+  BOT_TOKEN: string;
+  COSMOE_CREDENTIALS: KVNamespace;
+  COSMOE_STORAGE: KVNamespace;
+}
 
 export default {
-	async fetch(request, env, ctx): Promise<Response> {
-		return new Response('Hello World!');
-	},
-} satisfies ExportedHandler<Env>;
+  async fetch(
+    request: Request,
+    env: Env,
+    ctx: ExecutionContext,
+  ): Promise<Response> {
+    const bot = new Bot(env.BOT_TOKEN, { botInfo: JSON.parse(env.BOT_INFO) });
+    
+    setupCommands(bot, env);
+    
+    // Set up command menu
+    bot.api.setMyCommands([
+      { command: "start", description: "Start the bot and get welcome message" },
+      { command: "login", description: "Log in to your CosMoe account" },
+      { command: "events", description: "List upcoming events" },
+      { command: "history", description: "Show your booking history" },
+    ]).catch(error => {
+      console.error('Error setting bot commands:', error);
+    });
+
+    return webhookCallback(bot, "cloudflare-mod")(request);
+  },
+  
+  async scheduled(
+    controller: ScheduledController,
+    env: Env,
+    ctx: ExecutionContext
+  ): Promise<void> {
+    const bot = new Bot(env.BOT_TOKEN, { botInfo: JSON.parse(env.BOT_INFO) });
+
+    await handleScheduledEvent(bot, controller, env, ctx);
+  },
+};

+ 87 - 0
src/scheduler/index.ts

@@ -0,0 +1,87 @@
+import { Bot } from "grammy";
+import { CosmoeClient } from "../client/cosmoe";
+
+export interface Env {
+  BOT_INFO: string;
+  BOT_TOKEN: string;
+  COSMOE_CREDENTIALS: KVNamespace;
+  COSMOE_STORAGE: KVNamespace;
+}
+
+export async function handleScheduledEvent(
+  bot: Bot,
+  controller: ScheduledController,
+  env: Env,
+  ctx: ExecutionContext
+): Promise<void> {
+  console.log(`Running scheduled task at ${controller.scheduledTime}`);
+  
+  // Initialize Cosmoe API client for scheduled task
+  const cosmoeClient = new CosmoeClient();
+  
+  // Function to handle new event notifications
+  const handleNewEventNotifications = async () => {
+    try {
+      // Get latest event ID from storage
+      const storedLatestEventId = await env.COSMOE_STORAGE.get('latestEventId');
+      let latestEventId = storedLatestEventId ? parseInt(storedLatestEventId) : 0;
+      
+      // Fetch all events
+      const eventsResult = await cosmoeClient.getEvents();
+      
+      if (eventsResult.code === 200 && eventsResult.data.length > 0) {
+        // Filter new events (those with ID greater than latestEventId)
+        const newEvents = eventsResult.data.filter((event: any) => {
+          // Convert event ID to number for comparison
+          const eventId = typeof event.id === 'string' ? parseInt(event.id) : event.id;
+          return eventId > latestEventId;
+        });
+        console.log('New events: ', newEvents.length);
+        
+        if (newEvents.length > 0) {
+          // Get all registered user IDs from COSMOE_CREDENTIALS
+          const keys = await env.COSMOE_CREDENTIALS.list();
+          
+          for (const newEvent of newEvents) {
+            // Send notification to each registered user
+            for (const key of keys.keys) {
+              try {
+                const userId = parseInt(key.name);
+                console.log(`Sending notification to user ${key.name}...`);
+                
+                // Create a message for the new event
+                const message = `🎉 New event available: ${newEvent.name}\nDate: ${newEvent.event_date}\n\nView details: /event\\_${newEvent.id}`;
+                
+                // Send message to user via Telegram API
+                const sendResult = await bot.api.sendMessage(userId, message, { parse_mode: 'Markdown' });
+                
+                // Check if message was sent successfully by verifying the result
+                if (sendResult && sendResult.message_id) {
+                  console.log(`Successfully sent notification to user ${key.name}, message_id: ${sendResult.message_id}`);
+                } else {
+                  console.error(`Failed to send notification to user ${key.name}, unexpected response:`, sendResult);
+                }
+              } catch (error) {
+                console.error(`Error sending notification to user ${key.name}:`, error);
+              }
+            }
+          }
+          
+          // Update the latest event ID to the highest new event ID
+          const maxNewEventId = Math.max(...newEvents.map((event: any) => 
+            typeof event.id === 'string' ? parseInt(event.id) : event.id
+          ));
+          
+          if (maxNewEventId > latestEventId) {
+            await env.COSMOE_STORAGE.put('latestEventId', maxNewEventId.toString());
+          }
+        }
+      }
+    } catch (error) {
+      console.error('Error in handleNewEventNotifications:', error);
+    }
+  };
+  
+  // Execute the notification logic
+  await handleNewEventNotifications();
+}

+ 19 - 29
wrangler.jsonc

@@ -9,33 +9,23 @@
 	"compatibility_date": "2025-09-27",
 	"observability": {
 		"enabled": true
-	}
-	/**
-	 * Smart Placement
-	 * https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
-	 */
-	// "placement": {  "mode": "smart" }
-	/**
-	 * Bindings
-	 * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
-	 * databases, object storage, AI inference, real-time communication and more.
-	 * https://developers.cloudflare.com/workers/runtime-apis/bindings/
-	 */
-	/**
-	 * Environment Variables
-	 * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
-	 * Note: Use secrets to store sensitive data.
-	 * https://developers.cloudflare.com/workers/configuration/secrets/
-	 */
-	// "vars": {  "MY_VARIABLE": "production_value" }
-	/**
-	 * Static Assets
-	 * https://developers.cloudflare.com/workers/static-assets/binding/
-	 */
-	// "assets": {  "directory": "./public/",  "binding": "ASSETS" }
-	/**
-	 * Service Bindings (communicate between multiple Workers)
-	 * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
-	 */
-	// "services": [  {   "binding": "MY_SERVICE",   "service": "my-service"  } ]
+	},
+	"triggers": {
+		"crons": [
+			"*/1 * * * *"
+		]
+	},
+	"vars": {
+		"BOT_INFO": "{\"id\":8219164862,\"is_bot\":true,\"first_name\":\"CosMoe Bot\",\"username\":\"cosmoe_bot\",\"can_join_groups\":true,\"can_read_all_group_messages\":false,\"supports_inline_queries\":false,\"can_connect_to_business\":false,\"has_main_web_app\":false,\"has_topics_enabled\":false}"
+	},
+	"kv_namespaces": [
+		{
+			"binding": "COSMOE_CREDENTIALS",
+			"id": "668dab226bea4de6ab8ef8a5150b926c"
+		},
+		{
+			"binding": "COSMOE_STORAGE",
+			"id": "a3d8a40e24b0469d8627171d4ce8e51d"
+		}
+	]
 }