قطعیهای اینترنت بعد از ۱۸ و ۱۹ دی ۱۴۰۴ و بعدتر قطعی سراسری اینترنت پس از شروع جنگ سوم در اسفند ۱۴۰۴، برای خیلی از ما فقط یک اختلال ساده در دسترسی نبود؛ بیشتر شبیه این بود که ناگهان بخشی از ابزارهای روزمره، مسیرهای کاری، روشهای ارتباط با مخاطب و حتی نظم فکریمان از کار افتاده باشد. خیلیها مثل من، که دانش برنامهنویسیشان در ابتدا محدود، پراکنده و بیشتر بر اساس نیازهای عملی بود تا آموزش کلاسیک و دانشگاهی، مجبور شدند برای جبران این قطع دسترسیها، راهحلهای شخصی و اختصاصی بسازند. در چنین شرایطی دیگر منتظر ماندن برای آماده شدن ابزارهای کامل، مستندات بینقص یا تیم برنامهنویسی حرفهای چندان ممکن نبود؛ مسئله این بود که یک نیاز واقعی وجود داشت و باید برایش کاری میشد.
طبیعتاً بخش زیادی از این مسیر با آزمون و خطا پیش رفت. خیلی از چیزهایی که امروز شاید ساده بهنظر برسند، در همان لحظهها برای ما تبدیل به یک پروژه کامل میشدند: اتصال سایت به تلگرام، ساخت سیستم رزرو، مدیریت پرداخت، نمایش محتوای آموزشی، طراحی دیکشنری اختصاصی، کنترل وضعیت کاربران، ساخت پنل مدیریتی، پردازش دادهها، ذخیره متادیتا، ارسال خودکار، هماهنگی با ووکامرس، کار با کرونجابها، ساختاردهی به جستجو، و دهها جزئیات ریز دیگر که تا وقتی مجبور به ساختنشان نشوی، اهمیتشان را نمیفهمی. شاید بسیاری از این کارها از نگاه یک برنامهنویس حرفهای شبیه اختراع دوباره چرخ از ابتدا باشد؛ اما واقعیت این است که گاهی همین «چرخ دوباره ساختهشده» دقیقاً همان چیزی است که یک کسبوکار کوچک، یک معلم، یک تولیدکننده محتوا یا یک وبسایت شخصی برای زنده ماندن و ادامه دادن به آن نیاز دارد.
من هم از این قافله جدا نبودم. برای وبسایتی که بر پایه وردپرس ساخته شده، بهمرور شروع کردم به نوشتن پلاگینهای اختصاصی؛ نه از سر تفنن، بلکه برای پاسخ دادن به نیازهای کاملاً واقعی. بعضی از این پلاگینها برای رزرو کلاس و مدیریت پرداخت ساخته شدند، بعضی برای نمایش و ارسال مطالب شبیه کانال تلگرام، بعضی برای ساخت یک دیکشنری هوشمند آلمانی-فارسی، و بعضی دیگر برای سادهتر کردن تولید، انتشار و مدیریت محتوای آموزشی. در ظاهر شاید اینها فقط چند افزونه وردپرسی باشند، اما پشت هرکدامشان یک مسئله عملی، چندین بار شکست، اصلاح، بازنویسی، و در نهایت رسیدن به یک راهحل قابل استفاده وجود داشته است.
گزارشی که در ادامه میخوانید، صرفاً معرفی چند پلاگین نیست. بیشتر نوعی مستندسازی مسیر یادگیری، حل مسئله و ساخت ابزار در شرایط محدودیت است. تلاش کردهام در هر بخش، نه فقط بگویم افزونه چه کاری انجام میدهد، بلکه نشان بدهم کدام بخشهای فنی آن ارزش توجه دارند؛ از طراحی state برای رزروها و پرداختها گرفته تا مدیریت محدودیتهای تلگرام، جستجوی چندمرحلهای در دیکشنری، نرمالسازی فارسی و آلمانی، ایندکسگذاری، اعتبارسنجی، امنیت فرمها، کرونجابها، و اتصال چند سرویس مختلف به یکدیگر. شاید این نوشته برای آیندگان، یا حتی برای کسی که در موقعیتی مشابه قرار گرفته و باید با امکانات محدود چیزی بسازد، کمکی باشد؛ حتی اگر فقط به او یادآوری کند که گاهی بهترین راهحل، همان راهحلی است که خودت با نیاز واقعی، صبر، آزمون و خطا و کمی جسارت میسازی.
سالیان سال فعالیت در شبکههای اجتماعی و اطلاعرسانی درخصوص کلاسها، دورهها، اشتراکگذاری منابع آموزشی و سرگرمی بهزبان آلمانی، با قطع دسترسی از بین رفت. لاجرم من هم مجبور به کوچ به پیامرسانهای بومی شدم اما پراکندگی این پیامرسانها و عدم دسترسی به همۀ مخاطبین در یک پیامرسان واحد، باعث شد به این فکر بیوفتم که پلاگینی توسعه بدم تا با کمک اون، مطالب مندرج در تمام این پیامرسانها و تلگرام، تجمیع شده و در قالب یک کانال، در صفحۀ اصلی نمایش داده بشه؛ تا با این کار بتونم پیامها و اطلاعیهها رو یکجا به تمام مخاطبین این وبسایت برسونم. انجام این کار، با توجه به رویکرد سنگین نکردن لود صفحهای که شورت کد پلاگین درش قرار میگیره، چالش جدیدی برای من بود.
این پلاگین فقط یک «نمایشدهنده پست شبیه تلگرام» نیست؛ یک bridge کامل بین WordPress Content Model و Telegram Bot API است. از یک طرف Custom Post Type، متاباکس، shortcode، AJAX، infinite scroll، search و hashtag دارد؛ از طرف دیگر محدودیتهای تلگرام، caption splitting، media routing، retry روی rate-limit، حذف پیام، skip logic و reply mapping را مدیریت میکند.
موارد مهمی که در توسعۀ این پلاگین انجام/استفاده شدند رو در زیر میبینید.
1const POST_TYPE = 'tg_channel_post';2const META_TYPE = '_tg_media_type';3const META_URL = '_tg_media_url';4const META_HASHTAGS = '_tg_hashtags_str';5const META_TELEGRAM_SENT = '_tg_telegram_sent';6const META_TELEGRAM_MESSAGE_IDS = '_tg_telegram_message_ids';78const TELEGRAM_CAPTION_LIMIT = 1024;9const TELEGRAM_TEXT_LIMIT = 4096;10const TELEGRAM_DELETE_WINDOW = 172800;
بهجای پخش کردن stringهای جادویی وسط کد، کل state و constraintهای تلگرام در سطح کلاس تعریف شده. این یعنی اگر فردا limit تلگرام یا meta contract عوض شود، سیستم از یک نقطه کنترل میشود، نه با شکار و جایگزینی توی صد جای مختلف.
1$args = [2 'public' => true,3 'publicly_queryable' => true,4 'show_ui' => true,5 'show_in_menu' => false,6 'query_var' => true,7 'rewrite' => ['slug' => 'tg-post'],8 'supports' => ['title', 'editor'],9 'show_in_rest' => false,10];1112register_post_type(self::POST_TYPE, $args);
محتوای کانال به نوشتههای معمولی وردپرس وصلهپینه نشده. یک content model مستقل ساخته شده که URL، single page، admin list، meta و shortcode خودش را دارد. این یعنی افزونه از اول برای رشد طراحی شده، نه فقط برای «نمایش چند پست».
1$media_type = get_post_meta($post->ID, self::META_TYPE, true);23if (!in_array($media_type, ['none', 'image', 'video', 'file'])) {4 $media_type = 'none';5}67$video_poster = get_post_meta($post->ID, self::META_VIDEO_POSTER, true);8$file_icon = get_post_meta($post->ID, self::META_FILE_ICON, true);9$file_display_name = get_post_meta($post->ID, self::META_FILE_DISPLAY_NAME, true);
رسانه فقط یک URL خام نیست. نوع رسانه، کاور ویدئو، آیکون فایل و نام نمایشی فایل جدا مدل شدهاند. یعنی پلاگین با media بهعنوان یک content block رفتار میکند، نه یک فیلد ساده. نتیجهاش این است که همان محتوا هم در سایت درست render میشود، هم در تلگرام مسیر ارسال متفاوت میگیرد.
1if (!isset($_POST['tg_channel_meta_box_nonce']) ||2 !wp_verify_nonce($_POST['tg_channel_meta_box_nonce'], 'tg_channel_meta_box')) {3 return;4}56if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;7if (!current_user_can('edit_post', $post_id)) return;89$media_type = sanitize_text_field($_POST['tg_media_type'] ?? 'none');
این فقط فرم ادمین نیست؛ یک write-path امن است. nonce جلوی CSRF را میگیرد، autosave باعث خراب شدن meta نمیشود، capability کنترل میکند چه کسی اجازه تغییر دارد، و ورودی قبل از ذخیره sanitize میشود.
1private function extract_hashtags($text) {2 preg_match_all('/#([\w\-]+)/u', $text, $matches);3 return array_unique($matches[1]);4}56$hashtags = $this->extract_hashtags($post->post_content);7$hashtags_str = empty($hashtags) ? '' : ',' . implode(',', $hashtags) . ',';89update_post_meta($post_id, self::META_HASHTAGS, $hashtags_str);
هشتگها هر بار موقع نمایش از کل متن parse نمیشوند. زمان ذخیره extract و index میشوند. آن comma padding هم اتفاقی نیست؛ برای این است که موقع LIKE، تگهای مشابه مثل آلمان و آلمانی قاطی نشوند. این یعنی queryability از قبل طراحی شده.
1var loading = false;2var hasMore = true;3var offset = 0;4var currentHashtag = '';5var currentSearch = '';6var totalFetched = 0;7var isLoadingInitial = false;8var searchTimeout = null;
1function resetAndLoad() {2 offset = 0;3 totalFetched = 0;4 hasMore = true;5 isLoadingInitial = true;6 container.empty();7 loadMore(true);8}
فرانتاند stateful است. search، hashtag، pagination، loading و initial reset هرکدام state مستقل دارند. این یعنی وقتی کاربر سرچ میکند، هشتگ میزند یا اسکرول میکند، سیستم دچار duplicate load، race ساده یا offset خراب نمیشود. برای یک شورتکد وردپرسی، این سطح از state management قابل دفاع است.
1$('.tg-search-input').on('input', function() {2 if (searchTimeout) clearTimeout(searchTimeout);3 searchTimeout = setTimeout(performSearch, 500);4});
با هر کاراکتر تایپشده request نمیزند. نیمثانیه صبر میکند تا typing کاربر آرام شود، بعد سرچ را اجرا میکند. این یک جزئیات کوچک اما خیلی seniorپسند است؛ چون هم فشار روی admin-ajax کم میشود، هم UX روانتر میماند.
1$html = preg_replace_callback(2 '/<img[^>]+class="emoji"[^>]+alt="([^"]+)"[^>]*>/',3 function($m) {4 return $m[1];5 },6 $html7);89$html = preg_replace('/<img(?![^>]*class=["\'][^"\']*emoji)[^>]*>/is', '', $html);1011$html = strip_tags(12 $html,13 '<b><strong><i><em><u><s><strike><del><a><code><pre>'14);
محتوای وردپرس خام به تلگرام پرتاب نشده. emojiهای تصویری وردپرس به کاراکتر واقعی تبدیل میشوند، عکسهای داخل متن حذف میشوند، اما emojiها سالم میمانند. بعد فقط تگهایی باقی میمانند که تلگرام واقعاً میتواند parse کند. این یعنی یک adapter واقعی بین WordPress HTML و Telegram HTML نوشته شده.
1private function split_telegram_text($html, $limit = self::TELEGRAM_TEXT_LIMIT) {2 if (mb_strlen($this->strip_telegram_tags($html), 'UTF-8') <= $limit &&3 mb_strlen($html, 'UTF-8') <= $limit) {4 return [$html];5 }67 $plain = $this->strip_telegram_tags($html);8 $chunks = [];910 while (mb_strlen($plain, 'UTF-8') > 0) {11 $chunk = mb_substr($plain, 0, $limit, 'UTF-8');1213 $break_pos = max(14 mb_strrpos($chunk, "\n", 0, 'UTF-8') ?: 0,15 mb_strrpos($chunk, ' ', 0, 'UTF-8') ?: 016 );1718 if ($break_pos > 1000) {19 $chunk = mb_substr($chunk, 0, $break_pos, 'UTF-8');20 }2122 $chunks[] = trim($chunk);23 $plain = trim(mb_substr($plain, mb_strlen($chunk, 'UTF-8'), null, 'UTF-8'));24 }2526 return array_filter($chunks);27}
راهحل ساده این بود که متن بلند truncate شود. اینجا متن حفظ میشود و به chunkهای قابل ارسال تقسیم میشود. حتی تلاش شده split روی فاصله یا خط جدید اتفاق بیفتد تا پیامها وسط کلمه قطع نشوند. یعنی محدودیت Telegram Bot API بدون قربانی کردن محتوا مدیریت شده.
1if ($has_photo) {2 if (3 mb_strlen($this->strip_telegram_tags($full_text), 'UTF-8') <= self::TELEGRAM_CAPTION_LIMIT &&4 mb_strlen($full_text, 'UTF-8') <= self::TELEGRAM_CAPTION_LIMIT5 ) {6 $response_body = $this->telegram_send_photo(7 $bot_token,8 $chat_id,9 $media_url,10 $full_text,11 $reply_to_message_id12 );13 } else {14 $response_body = $this->telegram_send_photo(15 $bot_token,16 $chat_id,17 $media_url,18 '',19 $reply_to_message_id20 );21 }22}
تلگرام caption عکس و ویدئو را محدود میکند. این کد قبل از ارسال، هم طول متن plain را میسنجد هم طول HTML را. اگر جا شد، متن caption میشود؛ اگر نه، رسانه بدون caption میرود و متن بعداً جدا ارسال میشود. این یعنی سیستم بهجای fail کردن یا بریدن متن، مسیر ارسال را adaptive انتخاب میکند.
با قطع دسترسی به اینترنت، سایتهایی مثل گوگل ترنزلیت، wort.ir و هوش مصنوعیهای مختلف هم برای زبانآموزان از دسترس خارج شدند، گرچه باوجود اتصال داخلی چند پلتفرم هوش مصنوعی، امکان استفادۀ فوقالعاده محدود از یک سری امکانات وجود داشته و داره اما به دلیل استفادۀ این پلتفرمها از api، جوابها اغلب نادرست و یا گیجکننده بودند. بنابراین بلافاصله با قطع شدن اینترنت بعد از اعتراضات 18 دی 1404، تصمیم گرفتم دیتابیس جامعی از کلمات آلمانی و معانی فارسی اونها، بااضافۀ جملات نمونه، کلمات ترکیبی و همینطور کلمات همخانواده تهیه کنم و در مدت قطعی جنگ، کارهای کدنویسی افزونه رو مطابق استانداردهایی که این پایین توضیح میدم انجام بدم:
1$sql_entries = "CREATE TABLE $table_entries (2 id bigint(20) NOT NULL AUTO_INCREMENT,3 word_type varchar(50) NOT NULL,4 word_fa varchar(190) NOT NULL,5 word_de varchar(190) NOT NULL,6 grammar_info text,7 examples longtext,8 compounds longtext,9 PRIMARY KEY (id),10 KEY word_fa (word_fa),11 KEY word_de (word_de),12 FULLTEXT KEY ft_fa_de (word_fa, word_de)13) $charset_collate;";
1$sql_aliases = "CREATE TABLE $table_aliases (2 id bigint(20) NOT NULL AUTO_INCREMENT,3 entry_id bigint(20) NOT NULL,4 alias_word varchar(190) NOT NULL,5 lang varchar(10) NOT NULL,6 alias_type varchar(20) DEFAULT 'alias',7 PRIMARY KEY (id),8 KEY entry_id (entry_id),9 KEY alias_word (alias_word)10) $charset_collate;";
مدخل اصلی، aliasها و ترکیبها توی یک جدول قاطی نشدهاند. Entryها در جدول اصلی هستند و alias/compoundها در جدول جدا با entry_id نگهداری میشوند. این یعنی دیکشنری فقط یک JSON بزرگ ذخیرهشده در option نیست؛ یک مدل داده قابل query، قابل توسعه و قابل index دارد.
1FULLTEXT KEY ft_fa_de (word_fa, word_de)
1$check_index = $wpdb->get_results(2 "SHOW INDEX FROM $table_entries WHERE Key_name = 'ft_fa_de'"3);45if ( empty( $check_index ) ) {6 $wpdb->query(7 "ALTER TABLE $table_entries ADD FULLTEXT ft_fa_de (word_fa, word_de)"8 );9}
جستجو فقط با LIKE '%keyword%' پیاده نشده. روی ستونهای فارسی و آلمانی FULLTEXT گذاشته شده و حتی بعد از dbDelta هم وجود index دوباره چک میشود. این یعنی افزونه برای دیتای زیاد هم از اول فکر کرده، نه اینکه بعداً با کند شدن سرچ غافلگیر شود.
1'examples' => wp_json_encode(2 $entry['examples'],3 JSON_UNESCAPED_UNICODE4),5'compounds' => wp_json_encode(6 $entry['compounds'],7 JSON_UNESCAPED_UNICODE8)
مثالها و ترکیبها relationally بیش از حد خرد نشدهاند، ولی خام و بیساختار هم ذخیره نشدهاند. با JSON ذخیره میشوند و چون JSON_UNESCAPED_UNICODE استفاده شده، متن فارسی و آلمانی خوانا و سالم میماند. این یک trade-off خوب بین schema ساده و data richness است.
1if ( $existing ) {2 $conflicts[] = array(3 'new_data' => $entry,4 'existing_id' => $existing->id5 );6} else {7 if ( sdict_insert_entry_to_db( $entry ) ) {8 $inserted_count++;9 }10}
1<label>2 <input type="radio" name="conflict_action[<?php echo $index; ?>]" value="skip" checked>3 نادیده گرفتن (ادغام aliasها و ترکیبها)4</label>56<label>7 <input type="radio" name="conflict_action[<?php echo $index; ?>]" value="replace">8 جایگزینی کامل9</label>1011<label>12 <input type="radio" name="conflict_action[<?php echo $index; ?>]" value="duplicate">13 ثبت تکراری14</label>
Import فقط blind insert نیست. اگر مدخل از قبل وجود داشته باشد، سیستم conflict را نگه میدارد و به ادمین سه انتخاب میدهد: merge، replace یا duplicate. این یعنی data ingestion کنترلشده است، نه اینکه هر بار import جدید دیتابیس را پر از تکراری کند.
1$key = $alias_data['alias_word'] . '|' . $alias_data['lang'];23if ( ! in_array( $key, $existing_aliases ) ) {4 $wpdb->insert( $table_aliases, array(5 'entry_id' => $existing_id,6 'alias_word' => $alias_data['alias_word'],7 'lang' => $alias_data['lang'],8 'alias_type' => 'alias'9 ) );10}
وقتی مدخل تکراری import میشود، سیستم مجبور نیست کل مدخل را جایگزین کند. میتواند aliasهای جدید را به مدخل موجود اضافه کند، آن هم بدون ساخت alias تکراری. این یعنی افزونه بهجای overwrite کورکورانه، data enrichment انجام میدهد.
1if ( ! $compound_exists( $merged_compounds, $de_word ) ) {2 $merged_compounds[] = $new_comp;3 $added = true;45 if ( ! empty( $new_comp['de'] ) ) {6 $wpdb->insert( $table_aliases, array(7 'entry_id' => $existing_id,8 'alias_word' => sanitize_text_field( $new_comp['de'] ),9 'lang' => 'de',10 'alias_type' => 'compound'11 ) );12 }13}
ترکیبها فقط به JSON اضافه نمیشوند؛ همزمان در جدول aliasها هم با alias_type = compound ثبت میشوند. یعنی compound هم برای نمایش جزئیات وجود دارد، هم برای search candidate شدن. این یک طراحی دولایه است: data برای rendering، index برای retrieval.
1function sdict_normalize_persian_exact( $str ) {2 $str = str_replace( "\x{200C}", ' ', $str );34 $str = str_replace(5 array('ً','ٌ','ٍ','َ','ُ','ِ','ّ','ٓ','ٔ','ٕ'),6 '',7 $str8 );910 $str = str_replace( 'غ', 'ق', $str );11 $str = str_replace( array( 'ذ', 'ض', 'ظ' ), 'ز', $str );12 $str = preg_replace( '/[أإآؤئء]/u', 'ع', $str );1314 return mb_strtolower( $str, 'UTF-8' );15}
این دیکشنری فقط دنبال match ظاهری نیست. برای فارسی، نیمفاصله، اعراب و حتی خطاهای رایج آوایی/املایی مثل غ/ق یا ذ/ض/ظ/ز را normalize میکند. این دقیقاً همان چیزی است که یک دیکشنری واقعی فارسیمحور لازم دارد و با سرچ ساده SQL به دست نمیآید.
1function sdict_normalize_german_exact( $str ) {2 $str = str_replace( 'ä', 'ae', $str );3 $str = str_replace( 'ö', 'oe', $str );4 $str = str_replace( 'ü', 'ue', $str );5 $str = str_replace( 'ß', 'ss', $str );67 $str = str_replace( 'Ä', 'Ae', $str );8 $str = str_replace( 'Ö', 'Oe', $str );9 $str = str_replace( 'Ü', 'Ue', $str );1011 return mb_strtolower( $str, 'UTF-8' );12}
کاربر اگر Muenchen بزند، نباید از München محروم شود. اگر strasse بزند، باید Straße هم قابل پیدا شدن باشد. این normalization یعنی افزونه برای رفتار واقعی زبانآموز آلمانی طراحی شده، نه فقط برای دیتای تمیز و ایدهآل.
1function sdict_detect_language( $str ) {2 $persian_pattern = '/[\x{0600}-\x{06FF}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u';34 preg_match_all( $persian_pattern, $str, $matches );56 $persian_count = count( $matches[0] );7 $total_chars = mb_strlen( $str, 'UTF-8' );89 if ( $total_chars == 0 ) return 'de';1011 $ratio = $persian_count / $total_chars;1213 return ( $ratio > 0.3 ) ? 'fa' : 'de';14}
کاربر لازم نیست بگوید فارسی سرچ میکند یا آلمانی. سیستم خودش نسبت کاراکترهای فارسی را میسنجد و مسیر normalization، alias preload و match را بر همان اساس انتخاب میکند. این یعنی UX ساده مانده، ولی پشت صحنه search pipeline دوزبانه است.
1$priority1 = []; // مدخل اصلی دقیق2$priority2 = []; // ترکیب دقیق3$priority3 = []; // alias دقیق4$priority4 = []; // مدخل اصلی شامل کلمه کامل5$priority5 = []; // ترکیب شامل کلمه کامل6$priority6 = []; // alias شامل کلمه کامل
1if ( $main_word_norm === $exact_keyword_norm ) {2 $priority1[] = [3 'type' => 'entry',4 'entry' => $entry,5 'matched_text' => $main_word,6 'length' => mb_strlen( $main_word )7 ];8 continue;9}
نتایج فقط «هرچی پیدا شد» نیستند. سیستم matchها را به شش کلاس معنایی تقسیم میکند: مدخل دقیق، ترکیب دقیق، alias دقیق، مدخل شامل کلمه کامل، ترکیب شامل کلمه کامل، alias شامل کلمه کامل. این یعنی relevance ranking دستی و domain-aware دارد؛ دقیقاً چیزی که یک دیکشنری خوب را از سرچ خام جدا میکند.
تا قبل از قطعی اینترنت، برای هندل مسائل مربوط به رزرو کلاس، از افزونۀ bookingly استفاده میشد که با قطع اتصال، سرعت کار با این افزونه به شدت پایین اومد و خیلی از قابلیت های مدنظر غیرفعال شد. بنابراین ساخت سازوکاری جدید برای رزرو کلاس، مطابق شرایط و نیازهای خودم، در دستور کار قرار گرفت و این بزرگترین چرخی بود که از اول اختراع شد:
1$deadline = date('Y-m-d H:i:s', strtotime('-24 hours'));23$pending_bookings = $wpdb->get_results($wpdb->prepare(4 "SELECT * FROM {$this->table_name} 5 WHERE status = 'pending_card' 6 AND created_at <= %s",7 $deadline8));910foreach ($pending_bookings as $booking) {11 if ($booking->outside_iran == 1) {12 $wpdb->update(13 $this->table_name,14 ['status' => 'cancelled'],15 ['id' => $booking->id]16 );17 }18}1920$ten_minutes_ago = date('Y-m-d H:i:s', strtotime('-10 minutes'));2122$wpdb->delete(23 $this->table_name,24 [25 'status' => 'temp_card',26 'created_at <=' => $ten_minutes_ago,27 'outside_iran' => 028 ]29);
اینجا رزرو فقط یک رکورد ساده نیست؛ یک چرخه عمر دارد. temp_card، pending_card، confirmed و cancelled عملاً stateهای سیستم هستند. سیستم هم خودش cleanup میکند و هم وضعیتهای مالی را از هم جدا نگه میدارد. این یعنی مدیریت رزرو به شکل event/lifecycle دیده شده، نه صرفاً insert/update ساده.
1public function wc_payment_complete($order_id) {2 $order = wc_get_order($order_id);3 if (!$order) return;45 $is_processed = get_post_meta($order_id, '_gtbp_processed', true);6 if ($is_processed === 'yes') return;7 8 update_post_meta($order_id, '_gtbp_processed', 'yes');910 $cart = $order->get_meta('_gtbp_pending_cart');1112 if (!empty($cart) && is_array($cart)) {13 // create bookings, rooms, emails...14 }15}
این guard جلوی دوبارهپردازش شدن سفارش را میگیرد. توی WooCommerce و درگاهها، callback یا hook ممکن است بیش از یک بار اجرا شود. این تکه جلوی فاجعههایی مثل ساخت چند رزرو، چند اتاق و چند ایمیل برای یک پرداخت را میگیرد.
1private function sync_class_to_wc($class_id, $class_name, $price) {2 if (!class_exists('WooCommerce')) return false;34 $args = array(5 'post_type' => 'product',6 'meta_query' => array(7 array('key' => '_gtbp_class_id', 'value' => $class_id)8 ),9 'post_status' => array('publish', 'pending', 'draft', 'trash')10 );1112 $posts = get_posts($args);1314 if ($posts) {15 $product = wc_get_product($posts[0]->ID);1617 if($product->get_status() === 'trash') {18 $product->set_status('publish');19 }2021 $product->set_name('رزرو کلاس: ' . $class_name);22 $product->set_regular_price($price);23 $product->save();2425 return $posts[0]->ID;26 } else {27 $product = new WC_Product_Simple();28 $product->set_name('رزرو کلاس: ' . $class_name);29 $product->set_regular_price($price);30 $product->set_virtual(true);31 $product->set_catalog_visibility('hidden');32 $product->add_meta_data('_gtbp_class_id', $class_id, true);3334 return $product->save();35 }36}
کلاس آموزشی تبدیل به محصول واقعی فروشگاهی نشده؛ فقط از WooCommerce بهعنوان payment engine استفاده شده. محصول virtual و hidden است، یعنی در کاتالوگ فروشگاه دیده نمیشود، اما برای checkout و gatewayها قابل استفاده است. این یک abstraction خوب بین domain رزرو و domain فروشگاه است.
1public function ensure_table_columns() {2 global $wpdb;3 $table = $this->table_name;4 5 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") != $table) {6 return;7 }89 $required_columns = [10 'outside_iran' => "ALTER TABLE $table ADD outside_iran tinyint(1) NOT NULL DEFAULT 0 AFTER status",11 'roomeet_room_id' => "ALTER TABLE $table ADD roomeet_room_id varchar(100) DEFAULT NULL AFTER outside_iran",12 'roomeet_join_link' => "ALTER TABLE $table ADD roomeet_join_link text DEFAULT NULL AFTER roomeet_room_id",13 'bbb_moderator_link' => "ALTER TABLE $table ADD bbb_moderator_link text DEFAULT NULL AFTER roomeet_recording_link",14 'telegram_chat_id' => "ALTER TABLE $table ADD telegram_chat_id varchar(50) DEFAULT NULL AFTER bbb_moderator_link",15 'telegram_class_link_sent' => "ALTER TABLE $table ADD telegram_class_link_sent tinyint(1) NOT NULL DEFAULT 0 AFTER telegram_payment_reminder_sent"16 ];1718 $existing_columns = $wpdb->get_col("SHOW COLUMNS FROM $table");1920 foreach ($required_columns as $column => $sql) {21 if (!in_array($column, $existing_columns)) {22 $wpdb->query($sql);23 }24 }25}
این کد فقط برای نصب جدید نیست؛ برای آپدیت افزونه هم فکر کرده. اگر جدول از نسخه قدیمی باشد، ستونهای جدید را یکییکی و فقط در صورت نبودن اضافه میکند. این یعنی backward compatibility واقعی در محیط وردپرس، بدون نیاز به migration framework.
1private function normalize_bbb_base_url($url = '') {2 $url = trim($url);34 if (empty($url)) $url = self::BBB_DEFAULT_URL;5 if (!preg_match('#^https?://#i', $url)) $url = 'https://' . $url;67 $url = rtrim($url, "/ \t\n\r\0\x0B") . '/';89 if (substr($url, -15) !== '/bigbluebutton/') {10 $url = rtrim($url, '/') . '/bigbluebutton/';11 }1213 return $url;14}1516private function bbb_checksum($call_name, $query_string = '') {17 $secret = trim((string)get_option('gtbp_bbb_secret', self::BBB_DEFAULT_SECRET));18 return sha1($call_name . $query_string . $secret);19}2021private function bbb_api_url($call_name, $params = []) {22 $base = $this->normalize_bbb_base_url(23 get_option('gtbp_bbb_url', self::BBB_DEFAULT_URL)24 );2526 $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);2728 return $base . 'api/' . $call_name . '?' . $query .29 '&checksum=' . $this->bbb_checksum($call_name, $query);30}
اینجا integration با BBB فقط چسباندن چند string نیست. URL نرمال میشود، query طبق RFC ساخته میشود، checksum هم از call name + query + secret تولید میشود. این یعنی API boundary تمیز و قابل توسعه.
1private function create_jitsi_room($room_name, $student_name = null, $teacher_name = null) {2 if (get_option('gtbp_bbb_auto_create_enabled', '1') !== '1') return false;34 $meeting_id = 'gtbp-' . substr(5 hash('sha256', $room_name . microtime(true) . wp_rand()),6 0,7 188 );910 $attendee_pw = get_option('gtbp_bbb_attendee_password', 'ap');11 $moderator_pw = get_option('gtbp_bbb_moderator_password', 'mp');1213 // ...1415 return [16 'meeting_id' => $meeting_id,17 'student_link' => $this->get_bbb_join_url($meeting_id, $student_final_name, $attendee_pw),18 'teacher_link' => $this->get_bbb_join_url($meeting_id, $teacher_final_name, $moderator_pw)19 ];20}
یک لینک عمومی برای همه ساخته نشده. لینک زبانآموز با attendee password و لینک مدرس با moderator password ساخته میشود. یعنی تفکیک دسترسی در سطح خود BBB انجام شده، نه فقط در ظاهر پنل وردپرس.
1add_action('gtbp_check_pending_payments', array($this, 'check_pending_card_payments'));2add_action('gtbp_send_payment_reminders', array($this, 'send_payment_reminders'));3add_action('gtbp_check_bbb_recordings', array($this, 'check_bbb_recordings'));4add_action('gtbp_cleanup_class_links', array($this, 'cleanup_old_jitsi_links'));5add_action('gtbp_send_bot_payment_reminders', array($this, 'send_bot_payment_reminders'));6add_action('gtbp_send_upcoming_class_links', array($this, 'send_upcoming_class_links'));
1public function add_custom_cron_schedules($schedules) {2 if (!isset($schedules['gtbp_five_minutes'])) {3 $schedules['gtbp_five_minutes'] = [4 'interval' => 300,5 'display' => 'Every Five Minutes'6 ];7 }89 return $schedules;10}
1public function schedule_upcoming_class_links() {2 if (!wp_next_scheduled('gtbp_send_upcoming_class_links')) {3 wp_schedule_event(4 time(),5 'gtbp_five_minutes',6 'gtbp_send_upcoming_class_links'7 );8 }9}
سیستم فقط منتظر کلیک کاربر نیست. بخشی از کارها مثل reminder، cleanup، ارسال لینک نزدیک کلاس و بررسی recording به jobهای زمانبندیشده سپرده شدهاند. این یعنی افزونه رفتار backend واقعی دارد، نه فقط چند فرم وردپرسی.
1$ids = array_values(array_unique(array_filter($ids)));23if (empty($ids)) {4 wp_send_json_error('رزرو معتبری برای تایید پیدا نشد.');5}67$user = wp_get_current_user();89$placeholders = implode(',', array_fill(0, count($ids), '%d'));10$query_args = array_merge($ids, [$user->user_email]);1112$bookings = $wpdb->get_results($wpdb->prepare(13 "SELECT * FROM {$this->table_name} 14 WHERE id IN ($placeholders) 15 AND email = %s 16 AND status = 'temp_card'",17 $query_args18));1920if (empty($bookings)) {21 wp_send_json_error('رزرو مورد نظر یافت نشد یا منقضی شده است.');22}2324if (count($bookings) !== count($ids)) {25 wp_send_json_error(26 'بخشی از رزروهای این سبد قبلاً تایید/منقضی شدهاند. لطفاً صفحه را تازهسازی کنید.'27 );28}
اینجا فقط چند ID از کاربر گرفته نشده و blindly تأیید نشده. IDها normalize و unique میشوند، مالکیت با ایمیل کاربر چک میشود، وضعیت باید temp_card باشد، و اگر حتی یکی از آیتمهای سبد مشکل داشته باشد، عملیات متوقف میشود. این یعنی جلوگیری از partial confirmation و دستکاری ID.
1private function upsert_user_jitsi_link($user_id, $booking) {2 if (!$user_id || !$booking || empty($booking->roomeet_join_link)) {3 return false;4 }56 $current_links = get_user_meta($user_id, 'classlogin', true);78 if (!is_array($current_links)) {9 $current_links = [];10 }1112 $entry = [13 'booking_id' => $booking->id,14 'class_name' => $booking->class_name,15 'date' => $this->gregorian_to_jalali_string($booking->booking_date),16 'time' => $booking->booking_time,17 'link' => $booking->roomeet_join_link,18 'teacher_link' => $booking->bbb_moderator_link,19 'created_at' => current_time('mysql')20 ];2122 $updated = false;2324 foreach ($current_links as $idx => $link) {25 if (isset($link['booking_id']) && intval($link['booking_id']) === intval($booking->id)) {26 $current_links[$idx] = array_merge($link, $entry);27 $updated = true;28 break;29 }30 }3132 if (!$updated) {33 $current_links[] = $entry;34 }3536 usort($current_links, function($a, $b) {37 return strtotime($b['created_at']) - strtotime($a['created_at']);38 });3940 update_user_meta($user_id, 'classlogin', $current_links);4142 return true;43}
این یک append ساده نیست. اگر لینک همان booking قبلاً وجود داشته باشد update میشود؛ اگر وجود نداشته باشد insert میشود. بعد هم لینکها sort میشوند. یعنی رفتار upsert در user meta پیاده شده؛ چیزی که در وردپرس out-of-the-box برای آرایههای meta تمیز و آماده وجود ندارد.
1public function register_rest_routes() {2 register_rest_route('gtbp/v1', '/telegram-webhook', array(3 'methods' => 'POST',4 'callback' => array($this, 'handle_telegram_webhook'),5 'permission_callback' => '__return_true',6 ));7}
1add_action('rest_api_init', array($this, 'register_rest_routes'));
بات تلگرام با فایل PHP جدا، query string دستی یا endpoint شلخته پیاده نشده. از REST API خود وردپرس استفاده شده، با namespace اختصاصی gtbp/v1. این یعنی integration با lifecycle وردپرس، routing استاندارد، قابلیت توسعه، و تمیز بودن مرز ورودی webhook. البته برای نسخه production بهتر است permission_callback با secret واقعی سختگیرانهتر شود، ولی انتخاب معماری درست است.
اگر از گوگل به اینجا کشیده شدید، امیدوارم این اسنیپتها در توسعۀ محصولی که دارید روش کار میکنید براتون مفید باشن. اگر سوال یا نظری دارید میتونید این زیر توی کامنتها ازم بپرسید.