├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bot.ts ├── context.ts ├── deps.ts ├── helpers.ts ├── main.ts └── serve.ts /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dunkan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # report bot 2 | 3 | > Built using [grammY](https://grammy.dev). 4 | 5 | Simple time-aware report bot for Telegram. It listens for /report, /admin 6 | commands or @admin, @admins mentions in groups, and mentions all admins. Admins 7 | can set their timezone and unavailability time period in the bot's PM and only 8 | receive mentions when they are available. 9 | 10 | Working instance (public): [@ryportbot](https://telegram.me/ryportbot) 11 | 12 | To run locally, make sure you have installed [Deno CLI](https://deno.land). 13 | 14 | ```sh 15 | git clone https://github.com/dcdunkan/ryportbot.git 16 | cd ryportbot 17 | BOT_TOKEN="" deno run --allow-net --allow-env main.ts 18 | ``` 19 | 20 | Talk to [BotFather](https://t.me/botfather), and get yourself a `BOT_TOKEN`. 21 | 22 | Click 23 | [here](https://dash.deno.com/new?url=https://raw.githubusercontent.com/dcdunkan/ryportbot/main/serve.ts&env=BOT_TOKEN) 24 | to deploy your own instance to Deno Deploy. 25 | -------------------------------------------------------------------------------- /bot.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Bot, 3 | freeStorage, 4 | Fuse, 5 | getTimeZones, 6 | InlineKeyboard, 7 | lazySession, 8 | timeZonesNames, 9 | } from "./deps.ts"; 10 | import { 11 | _24to12, 12 | admins, 13 | containsAdminMention, 14 | esc, 15 | getDisplayTime, 16 | getRandomReply, 17 | getUser, 18 | getUserTime, 19 | hoursKeyboard, 20 | HTML, 21 | isAvailable, 22 | nonAdmins, 23 | REPORT_BOT_REPLIES, 24 | UNAVAIL_KEYBOARD1, 25 | } from "./helpers.ts"; 26 | import { 27 | Context, 28 | customMethods, 29 | ReportContext, 30 | SessionData, 31 | } from "./context.ts"; 32 | 33 | export const TOKEN = Deno.env.get("BOT_TOKEN"); 34 | if (!TOKEN) throw new Error("BOT_TOKEN is missing"); 35 | export const bot = new Bot(TOKEN); 36 | 37 | const storage = freeStorage(bot.token); 38 | bot.use(lazySession({ storage, initial: () => ({ dnd: false }) })); 39 | bot.use(customMethods); 40 | bot.catch(console.error); 41 | // Assign some always-use-parameters to the payload. 42 | bot.api.config.use((prev, method, payload, signal) => 43 | prev(method, { 44 | ...payload, 45 | disable_web_page_preview: true, 46 | allow_sending_without_reply: true, 47 | }, signal) 48 | ); 49 | 50 | const pm = bot.chatType("private"); 51 | const grp = bot.chatType(["group", "supergroup"]); 52 | const exceptChannel = bot.chatType(["private", "group", "supergroup"]); 53 | 54 | async function reportHandler(ctx: ReportContext) { 55 | const reportedMsg = ctx.msg.reply_to_message; 56 | if (!reportedMsg) { 57 | return await ctx.comment("Reply /report to a message."); 58 | } 59 | 60 | // Connected channel's forwarded post. 61 | if (reportedMsg.is_automatic_forward) return; 62 | 63 | const report = getUser(reportedMsg); 64 | 65 | if (report.id === ctx.me.id) { 66 | return await ctx.comment(getRandomReply(REPORT_BOT_REPLIES)); 67 | } 68 | 69 | // Maybe as channels? 70 | if (reportedMsg.sender_chat === undefined) { 71 | const member = await ctx.getChatMember(report.id); 72 | if (member.status === "administrator" || member.status === "creator") { 73 | return; 74 | } 75 | } 76 | 77 | let msg = `Reported ${esc(report.first_name)} [${report.id}]\n`; 82 | 83 | let availableAdmins = 0; 84 | const admins = await ctx.getChatAdministrators(); 85 | 86 | await Promise.all(admins.map(async (admin) => { 87 | if (admin.is_anonymous || admin.user.is_bot) return; 88 | const user = await storage.read(`${admin.user.id}`); 89 | if (user) { 90 | if (user.dnd) return; 91 | // Admin is currently unavailable as per the timezone and interval they set. 92 | if (!isAvailable(user)) return; 93 | } 94 | 95 | availableAdmins++; 96 | msg += admin.user.username 97 | ? `@${esc(admin.user.username)} ` 98 | : `${ 99 | esc(admin.user.first_name) 100 | } `; 101 | })); 102 | 103 | // If all admins are unavailable at the moment, just tag the chat creator. 104 | if (availableAdmins === 0) { 105 | const creator = admins.find((admin) => admin.status === "creator"); 106 | // There might be no creator or the admins are anonymous. 107 | if (creator) { 108 | msg += creator.user.username 109 | ? `@${esc(creator.user.username)} ` 110 | : `${ 111 | esc(creator.user.first_name) 112 | } `; 113 | } 114 | } 115 | 116 | try { 117 | await ctx.deleteMessage(); 118 | } catch (_e) { 119 | // Maybe the "/report" message got deleted :/ 120 | // Or Bot doesn't have permission to delete. 121 | } 122 | 123 | await ctx.reply(msg, { 124 | parse_mode: "HTML", 125 | reply_markup: new InlineKeyboard().text("Handled", "handled"), 126 | reply_to_message_id: reportedMsg.message_id, 127 | }); 128 | } 129 | 130 | grp.callbackQuery([ 131 | "handled", 132 | "mark-as-handled", // for the existing messages 133 | ]).branch(admins, async (ctx) => { 134 | await ctx.answerCallbackQuery("Marked as handled."); 135 | await ctx.deleteMessage(); 136 | }, (ctx) => ctx.alert("Not allowed.")); 137 | 138 | grp.command(["report", "admin"]) 139 | .filter(nonAdmins, reportHandler); 140 | 141 | grp.on(["msg:entities:mention", "msg:caption_entities:mention"]) 142 | .filter(containsAdminMention) 143 | .filter(nonAdmins, reportHandler); 144 | 145 | // the following also works. but not as good as the above filtering. 146 | // grp.hears(/.*(\s|^)(@admins?)\b.*/g, reportHandler); 147 | 148 | pm.command( 149 | ["report", "admin"], 150 | (ctx) => ctx.reply("That works only in groups."), 151 | ); 152 | 153 | pm.command(["tz", "timezone"], async (ctx) => { 154 | const session = await ctx.session; 155 | const statusText = session.tz 156 | ? `You have set ${session.tz} as your timezone. Use /clear_tz to remove it.` 157 | : `You haven't configured a timezone yet. \ 158 | You can find your timezone location by going here, or by searching one.`; 159 | 160 | if (!ctx.match) { 161 | return await ctx.reply( 162 | `Pass your timezone as an argument. 163 | Examples 164 | - /tz Europe/Berlin 165 | - /tz berlin 166 | - /tz berl (Search) 167 | 168 | ${statusText} 169 | 170 | Timezone 171 | You can set a timezone, and I won't tag you for reports while you're unavailable. \ 172 | By default, you're considered to be unavailable, if it is night time at your location. \ 173 | You can customize the default unavailability period (12AM to 6AM) using the /unavail command.`, 174 | HTML, 175 | ); 176 | } 177 | 178 | const timezone = ctx.match.trim(); 179 | if (timezone.length === 1) { 180 | return await ctx.reply( 181 | "What is this? Specify your timezone a little bit more. At least two characters.", 182 | ); 183 | } 184 | 185 | // this should never be a global constant since timezone 186 | // offset can change due to DST. 187 | const timezones = getTimeZones(); 188 | 189 | if (timeZonesNames.includes(timezone)) { 190 | const tz = timezones.find((tz) => tz.group.includes(timezone)); 191 | // it is assured that there will be one. But still its nice to catch every case. 192 | if (!tz) { 193 | return await ctx.answerCallbackQuery("Couldn't find the timezone"); 194 | } 195 | 196 | if (!session.interval) session.interval = [0, 6]; // 12AM to 6AM 197 | ctx.session = { 198 | ...session, 199 | tz: timezone, // never store offset! 200 | }; 201 | 202 | const userTime = getUserTime(tz.currentTimeOffsetInMinutes); 203 | return await ctx.reply( 204 | `Timezone location has been set to ${timezone}. \ 205 | I guess the time is ${getDisplayTime(userTime)} at your place.`, 206 | HTML, 207 | ); 208 | } 209 | 210 | const results = new Fuse(timezones, { 211 | findAllMatches: true, 212 | minMatchCharLength: timezone.length, 213 | threshold: 0.5, 214 | keys: ["group", "countryName", "mainCities"], 215 | }).search(timezone).splice(0, 100); 216 | 217 | // invalid 218 | if (!results.length) { 219 | return await ctx.reply( 220 | "Couldn't find any timezones related to that. Please enter something valid.", 221 | ); 222 | } 223 | 224 | const kb = new InlineKeyboard(); 225 | for (let i = 0; i < results.length; i++) { 226 | const { item } = results[i]; 227 | kb.text(item.name, `set-loc_${item.name}`); 228 | if (i % 2 === 1) kb.row(); 229 | } 230 | 231 | return await ctx.reply(`Did you mean...?`, { reply_markup: kb }); 232 | }); 233 | 234 | pm.callbackQuery(/set-loc_(.+)/, async (ctx) => { 235 | if (!ctx.match) { 236 | return await ctx.answerCallbackQuery("Invalid query :("); 237 | } 238 | 239 | const session = await ctx.session; 240 | await ctx.answerCallbackQuery(); 241 | const location = ctx.match[1]; 242 | const tz = getTimeZones().find((tz) => tz.group.includes(location)); 243 | if (!tz) { 244 | return await ctx.answerCallbackQuery("Couldn't find the timezone"); 245 | } 246 | 247 | if (!session.interval) session.interval = [0, 6]; // 12AM to 6AM 248 | ctx.session = { 249 | ...session, 250 | tz: location, 251 | }; 252 | 253 | const userTime = getUserTime(tz.currentTimeOffsetInMinutes); 254 | await ctx.editMessageText( 255 | `Timezone location has been set to ${location}. \ 256 | I guess the time is ${getDisplayTime(userTime)} at your place.`, 257 | HTML, 258 | ); 259 | }); 260 | 261 | pm.command("clear_tz", async (ctx) => { 262 | const session = await ctx.session; 263 | ctx.session = { 264 | ...session, 265 | tz: undefined, 266 | interval: undefined, 267 | }; 268 | await ctx.reply( 269 | "Timezone has been cleared. You can set a new one using the /tz command.", 270 | ); 271 | }); 272 | 273 | pm.command("dnd", async (ctx) => { 274 | const dnd = (await ctx.session).dnd; 275 | (await ctx.session).dnd = !dnd; 276 | await ctx.reply( 277 | !dnd 278 | ? "Enabled Do Not Disturb mode. You won't receive any mentions until you disable it using /dnd again." 279 | : "Disabled Do Not Disturb mode. You'll receive reports when you're available.", 280 | ); 281 | }); 282 | 283 | // Unavailability feature 284 | pm.command("unavail", async (ctx) => { 285 | const { interval, tz } = await ctx.session; 286 | if (!tz) { 287 | return await ctx.reply( 288 | "You need to set a timezone using /tz to use this feature.", 289 | ); 290 | } 291 | 292 | const statusText = interval 293 | ? `Your current unavailability time period is \ 294 | from ${_24to12(interval[0])} to ${_24to12(interval[1])}. \ 295 | You can change it using the button below.` 296 | : `You have disabled this feature entirely. You can enable it using the button below.`; 297 | 298 | await ctx.reply( 299 | `${statusText} 300 | 301 | In your daily life, you're probably not be available 24x7. You need sleep, and you may have work. \ 302 | So while you're unavailable, it is a disturbance if the bot tags you when people /report. \ 303 | With this feature you can set a time period during which you are expected to be unavailable. \ 304 | If such an unavailability period is set, the bot will check if you're available or not before tagging you. 305 | 306 | Note: This feature won't work if you're the chat creator and no other admins are available. 307 | 308 | — You can disable this feature with /disable_unavail and receive mentions all the time. 309 | — Run /am_i_available to check if you are available now or not. (debug)`, 310 | { 311 | ...HTML, 312 | reply_markup: new InlineKeyboard() 313 | .text(interval ? "Change" : "Enable", "change-unavail-time"), 314 | }, 315 | ); 316 | }); 317 | 318 | pm.callbackQuery("change-unavail-time", async (ctx) => { 319 | const session = await ctx.session; 320 | if (!session.tz) { 321 | return await ctx.alert( 322 | "You need to set a timezone using the /tz command first to use this feature.", 323 | ); 324 | } 325 | await ctx.answerCallbackQuery(); 326 | await ctx.editMessageText( 327 | "So you're unavailable, starting from?", 328 | { reply_markup: UNAVAIL_KEYBOARD1 }, 329 | ); 330 | }); 331 | 332 | pm.callbackQuery(/unavail-time-start_(\d+)/, async (ctx) => { 333 | if (!ctx.match) { 334 | return await ctx.answerCallbackQuery("Invalid query :("); 335 | } 336 | const session = await ctx.session; 337 | if (!session.tz) { 338 | return await ctx.alert( 339 | "You need to set a timezone using the /tz command first to use this feature.", 340 | ); 341 | } 342 | const startsAt = parseInt(ctx.match[1]); 343 | await ctx.answerCallbackQuery(`From ${_24to12(startsAt)}, to...`); 344 | const kb = hoursKeyboard(startsAt + 1, `unavail-time-end_${startsAt}`, false); 345 | await ctx.editMessageText("When you become available again?", { 346 | reply_markup: kb, 347 | }); 348 | }); 349 | 350 | pm.callbackQuery(/unavail-time-end_(\d+)_(\d+)/, async (ctx) => { 351 | if (!ctx.match) { 352 | return await ctx.answerCallbackQuery("Invalid query :("); 353 | } 354 | const session = await ctx.session; 355 | if (!session.tz) { 356 | return await ctx.alert( 357 | "You need to set a timezone using the /tz command first to use this feature.", 358 | ); 359 | } 360 | await ctx.answerCallbackQuery(); 361 | const startsAt = parseInt(ctx.match[1]); 362 | const endsAt = parseInt(ctx.match[2]); 363 | (await ctx.session).interval = [startsAt, endsAt]; 364 | await ctx.editMessageText( 365 | `So you'll be unavailable from ${_24to12(startsAt)} to ${_24to12(endsAt)}. \ 366 | I'll remember that and I won't tag you at that time unless it is necessary.`, 367 | ); 368 | }); 369 | 370 | pm.command("disable_unavail", async (ctx) => { 371 | if ((await ctx.session).interval === undefined) { 372 | return await ctx.reply("Already disabled."); 373 | } 374 | (await ctx.session).interval = undefined; 375 | return await ctx.reply("Unavailability feature have been disabled.", { 376 | reply_markup: new InlineKeyboard() 377 | .text("Enable it back", "change-unavail-time"), 378 | }); 379 | }); 380 | 381 | pm.command("am_i_available", async (ctx) => { 382 | const session = await ctx.session; 383 | let msg = !session.tz 384 | ? "I don't know. You haven't set any timezone yet. So, I can't really tell." 385 | : session.interval 386 | ? `Seems like you are ${ 387 | isAvailable(session) ? "" : "un" 388 | }available right now.` 389 | : "Not sure about it since you disabled the /unavail-ability feature."; 390 | 391 | if (session.dnd) { 392 | msg += session.interval && !isAvailable(session) 393 | ? " And you also have /dnd enabled." 394 | : " But you have /dnd enabled right now. So, I guess you're unavailable rn."; 395 | } 396 | await ctx.reply(msg); 397 | }); 398 | 399 | exceptChannel.command("start", async (ctx) => { 400 | const { tz } = await ctx.session; 401 | const helpText = tz 402 | ? "" 403 | : "\nIn order to do that, I need your /timezone. You can simply set one by using /tz. \ 404 | So I can decide whether you are available or not based on your /unavail-ability time period and timezone, before mentioning you. \ 405 | I also help you to go to Do Not Disturb mode (/dnd), which makes you fully unavailable until you disable it.\n"; 406 | 407 | await ctx.reply( 408 | ctx.chat.type !== "private" 409 | ? "Hi! For /help, ping me in private." 410 | : `Hi! I can mention admins in a group chat when someone reports something. \ 411 | But, unlike other bots which do the same thing, I only tag you when you're available. 412 | ${helpText} 413 | See /help for more information.`, 414 | ); 415 | }); 416 | 417 | exceptChannel.command("help", async (ctx) => { 418 | await ctx.reply( 419 | ctx.chat.type !== "private" 420 | ? "Use /report to report someone to admins. Ping me in private for more help." 421 | : `Add me to your group so I can help your group members to /report other members (such as spammers, etc) to the admins of the group. \ 422 | I'm different from other bots which does the same because I'm aware of time! 423 | 424 | How am I time-aware? 425 | Well, I am not actually time-aware without you setting your /timezone. \ 426 | If you set one, an unavailability time period is also set (which you can customize using /unavail). \ 427 | That's it! From then on, whenever someone use the /report command in a group that you're admin, \ 428 | I'll check your current time, and if you're unavailable, I won't mention you. 429 | 430 | Note: No matter how busy you are, you will receive mentions if you're the chat creator and if no other admins are available at the moment. 431 | 432 | Do Not Disturb mode 433 | You can enable or disable the Do Not Disturb mode using /dnd. \ 434 | When you have it enabled, the bot won't mention you at all. 435 | 436 | About 437 | The idea: https://t.me/grammyjs/63768 438 | https://github.com/dcdunkan/ryportbot 439 | By @dcdunkan from @dcbots.`, 440 | HTML, 441 | ); 442 | }); 443 | 444 | await bot.init(); 445 | await bot.api.setMyCommands([ 446 | { command: "tz", description: "Set timezone" }, 447 | { command: "clear_tz", description: "Clear timezone" }, 448 | { command: "unavail", description: "Set unavailability time period" }, 449 | { command: "dnd", description: "Toggle Do Not Disturb mode" }, 450 | { command: "am_i_available", description: "Am I available?" }, 451 | { command: "help", description: "Help & About" }, 452 | ], { scope: { type: "all_private_chats" } }); 453 | -------------------------------------------------------------------------------- /context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatTypeContext, 3 | CommandContext, 4 | Context as BaseContext, 5 | Filter, 6 | LazySessionFlavor, 7 | Message, 8 | NextFunction, 9 | ParseMode, 10 | } from "./deps.ts"; 11 | 12 | export interface SessionData { 13 | dnd: boolean; // Do not disturb mode status 14 | interval?: [number, number]; // Unavailability [StartHour, EndHour] 15 | tz?: string; // User's entered timezone 16 | } 17 | 18 | interface CustomContextFlavor { 19 | alert(text: string): Promise; 20 | comment(text: string, options?: ParseMode): Promise; 21 | } 22 | 23 | export type Context = 24 | & BaseContext 25 | & LazySessionFlavor 26 | & CustomContextFlavor; 27 | 28 | type GroupContext = ChatTypeContext; 29 | 30 | export type ReportContext = 31 | | CommandContext 32 | | Filter< 33 | GroupContext, 34 | "msg:entities:mention" | "msg:caption_entities:mention" 35 | >; 36 | 37 | export async function customMethods(ctx: Context, next: NextFunction) { 38 | ctx.alert = (text: string) => 39 | ctx.answerCallbackQuery({ text, show_alert: true }); 40 | ctx.comment = (text: string, parseMode?: ParseMode) => 41 | ctx.reply(text, { parse_mode: parseMode }); 42 | await next(); 43 | } 44 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | // grammY stuff 2 | export { 3 | Bot, 4 | type ChatTypeContext, 5 | type CommandContext, 6 | Context, 7 | type Filter, 8 | InlineKeyboard, 9 | lazySession, 10 | type LazySessionFlavor, 11 | type NextFunction, 12 | webhookCallback, 13 | } from "https://deno.land/x/grammy@v1.12.1/mod.ts"; 14 | export type { 15 | Message, 16 | ParseMode, 17 | } from "https://deno.land/x/grammy@v1.12.1/types.ts"; 18 | export { freeStorage } from "https://deno.land/x/grammy_storages@v2.0.2/free/src/mod.ts"; 19 | 20 | // utils 21 | export { 22 | getTimeZones, 23 | timeZonesNames, 24 | } from "https://cdn.skypack.dev/@vvo/tzdb@v6.62.0?dts"; 25 | // @deno-types="https://deno.land/x/fuse@v6.4.1/dist/fuse.d.ts" 26 | export { default as Fuse } from "https://deno.land/x/fuse@v6.4.1/dist/fuse.esm.min.js"; 27 | 28 | -------------------------------------------------------------------------------- /helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatTypeContext, 3 | Filter, 4 | getTimeZones, 5 | InlineKeyboard, 6 | Message, 7 | } from "./deps.ts"; 8 | import { Context, SessionData } from "./context.ts"; 9 | 10 | // Constants 11 | // Random replies if someone reports the bot itself. 12 | export const REPORT_BOT_REPLIES = [ 13 | "You can't report me.", 14 | "Nice try", 15 | "Oh, come on.", 16 | "what?", 17 | "Hmm", 18 | "Nope", 19 | ]; 20 | export const UNAVAIL_KEYBOARD1 = hoursKeyboard(0, "unavail-time-start"); 21 | 22 | // Helpers 23 | export function getUserTime(offset: number) { 24 | const time = new Date(); 25 | const t = time.getTime() + (time.getTimezoneOffset() * 60000) + 26 | (offset * 60000); 27 | return new Date(t); 28 | } 29 | 30 | export function getDisplayTime(time: Date) { 31 | return `${time.getHours().toString().padStart(2, "0")}:${ 32 | time.getMinutes().toString().padStart(2, "0") 33 | }`; 34 | } 35 | 36 | function checkIfInBetween(offset: number, start: number, end: number) { 37 | let hours = getUserTime(offset).getHours(); 38 | if (start > hours) hours += 24; 39 | // it is made sure that start and end will never be equal 40 | return start < end 41 | ? hours >= start && hours < end // case 7AM to 6PM (7 to 18) 42 | : hours >= start && hours < (end + 24); // cases like 11PM to 6AM (23 to 6) 43 | } 44 | 45 | export function isAvailable({ tz, interval }: SessionData) { 46 | // If anyone haven't set anything, then consider as they are available. 47 | if (!tz || !interval) return true; 48 | const offset = getTimeZones().find((t) => t.group.includes(tz)) 49 | ?.currentTimeOffsetInMinutes!; // why? DST!! 50 | return !checkIfInBetween(offset, interval[0], interval[1]); 51 | } 52 | 53 | export function getRandomReply(replies: string[]) { 54 | return replies[Math.floor(Math.random() * replies.length)]; 55 | } 56 | 57 | export function hoursKeyboard( 58 | startsAt: number, 59 | prefix: string, 60 | includeLast = true, 61 | ) { 62 | const kb = new InlineKeyboard(); 63 | let actualIndex = 0; 64 | let limit = (includeLast ? 25 : 24); 65 | if (startsAt === 24) { 66 | startsAt = 0; 67 | limit--; 68 | } 69 | for (let i = startsAt; i < limit; i++) { 70 | kb.text(_24to12(i), `${prefix}_${i}`); 71 | if (i === 23) { 72 | i = -1; // -1? i gets incremented to 0 in the next iteration. 73 | limit = startsAt - 1; 74 | } 75 | actualIndex++; 76 | if (actualIndex % 4 === 0) kb.row(); 77 | } 78 | return kb; 79 | } 80 | 81 | export function _24to12(x: number) { 82 | while (x > 23) x -= 24; 83 | return x === 0 84 | ? "12 AM" 85 | : x > 11 && x < 24 86 | ? (x === 12 ? 12 : x - 12).toString().padStart(2, "0") + " PM" 87 | : x.toString().padStart(2, "0") + " AM"; 88 | } 89 | 90 | export function getUser(msg: Message) { 91 | return msg.sender_chat?.type === "channel" 92 | ? { 93 | first_name: msg.sender_chat.title, 94 | id: msg.sender_chat.id, 95 | username: msg.sender_chat.username, 96 | is_user: false, 97 | } 98 | : msg.sender_chat?.type === "group" 99 | ? { 100 | first_name: msg.sender_chat.title, 101 | id: msg.sender_chat.id, 102 | username: undefined, 103 | is_user: false, 104 | } 105 | : msg.sender_chat?.type === "supergroup" 106 | ? { 107 | first_name: msg.sender_chat.title, 108 | id: msg.sender_chat.id, 109 | username: msg.sender_chat.username, 110 | is_user: false, 111 | } 112 | : { ...msg.from!, is_user: msg.from?.is_bot ? false : true }; 113 | } 114 | 115 | export function esc(str: string) { 116 | return str 117 | .replace(/&/g, "&") 118 | .replace(//g, ">"); 120 | } 121 | 122 | // Option builders 123 | export const HTML = { parse_mode: "HTML" as const }; 124 | 125 | // Predicates 126 | export async function admins(ctx: Context) { 127 | const author = await ctx.getAuthor(); 128 | if (author.status === "administrator" || author.status === "creator") { 129 | return true; 130 | } 131 | return false; 132 | } 133 | 134 | export async function nonAdmins(ctx: Context) { 135 | return !(await admins(ctx)); 136 | } 137 | 138 | export function containsAdminMention( 139 | ctx: Filter< 140 | ChatTypeContext, 141 | "msg:entities:mention" | "msg:caption_entities:mention" 142 | >, 143 | ) { 144 | const text = (ctx.msg.text ?? ctx.msg.caption)!; 145 | return (ctx.msg.entities ?? ctx.msg.caption_entities) 146 | .find((e) => { 147 | const t = text.slice(e.offset, e.offset + e.length); 148 | return e.type === "mention" && (t === "@admin" || t === "@admins"); 149 | }) !== undefined; 150 | } 151 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { bot } from "./bot.ts"; 2 | import { run } from "https://deno.land/x/grammy_runner@v1.0.4/mod.ts"; 3 | 4 | run(bot); 5 | console.log(`running @${bot.botInfo.username}`); 6 | -------------------------------------------------------------------------------- /serve.ts: -------------------------------------------------------------------------------- 1 | import { webhookCallback } from "./deps.ts"; 2 | import { bot } from "./bot.ts"; 3 | 4 | const handleUpdate = webhookCallback(bot, "std/http"); 5 | 6 | Deno.serve(async (req) => { 7 | if (new URL(req.url).pathname === `/${bot.token}` && req.method === "POST") { 8 | if (req.method === "POST") { 9 | try { 10 | return await handleUpdate(req); 11 | } catch (err) { 12 | console.error(err); 13 | return new Response(); 14 | } 15 | } 16 | } 17 | 18 | return Response.redirect(`https://telegram.me/${bot.botInfo.username}`); 19 | }); 20 | --------------------------------------------------------------------------------