Showing Daily Report: Oct 23, 2026
Checking if each member sent at least 1 photo per day in their assigned topic before 23:59:59 Cairo time.
0
Across selected period & topics
0
Sent ≥ 1 photo on active days
0
Missed at least 1 required day
0%
Fulfilled member-days in period
Sent vs. Missing Members by Topic
Compliance breakdown across CS, Patrolling, Reports, Splash, and Emoji
Daily Photo Volume Trend (Cairo Time)
Total photos collected per day across selected topic(s)
Member Compliance & Photo Count Per Topic
Automatically matched by Telegram Display Name • Green = Sent daily, Red = Missing day(s)
Manage Team Roster Per Topic
Add or edit expected team members for CS, Patrolling, Reports, Splash, and Emoji. If a member belongs to multiple topics, add an entry for each topic they must report in.
Simulate / Manually Record a Telegram Photo Post
Test how the bot captures a member's Telegram display name, topic, and Cairo timestamp when they send a task completion photo.
Telegram Topic IDs (`message_thread_id`)
Telegram assigns an ID to each forum topic. You can type /register CS in Telegram or set IDs here:
Collected Photo Submissions Log
Every photo captured by the bot with Cairo Date, Exact Timestamp, Topic, and Member Display Name
Connect Your Telegram Bot & Google Sheet Backend
One single Telegram bot in Review | CS | Patrolling | Monitoring listens to all 5 topics (CS, Patrolling, Reports, Splash, Emoji). Use the Webhook + Google Sheet mode below so photos are captured 24/7 even when the website is closed, saved permanently in the Sheet (1 year+ of history), and loaded into this site every time it is opened.
Used only in your browser to link your bot's webhook directly to your Google Apps Script URL.
Paste the Web App URL after deploying the Google Apps Script code below.
5-Minute Free Setup Guide
-
1
Create Bot in @BotFather
Open Telegram, message @BotFather, send
/newbot, and copy the Bot Token. Then send/setprivacy→ select your bot → choose Disable (required — otherwise Telegram hides group photos and commands from the bot). -
2
Add Bot to Your Group as Admin
Add the bot to Review | CS | Patrolling | Monitoring and promote it to Admin (only "Read Messages" is needed). If you changed /setprivacy after adding the bot, remove it from the group and add it back — Telegram only applies the new privacy mode when the bot re-joins.
-
3
Paste Script in Google Sheets
Create a blank Google Sheet, click Extensions → Apps Script, paste the code on the right, and click Deploy → New deployment → Web app (Execute as: Me, Who has access: Anyone).
-
4
Link Webhook & Register Topics
Paste your Bot Token and Web App URL above and click Activate Telegram Webhook Now. If the Full Diagnosis reports the
302 Founderror (Google Apps Script webhooks cannot accept Telegram's POST), click the orange "Switch to 24/7 Trigger Mode (Fix 302 Error)" button and follow its 3 steps — Google then polls Telegram every 60 seconds, 24/7, even when the website is closed, and the full history loads automatically whenever the site is opened. Inside each of your 4 Telegram topics, send once:
/register CS
/register Patrolling
/register Reports
/register Splash
/register Emoji
A member is tracked separately under every topic they send photos in — no extra setup per member. If photos were recorded as "Topic #id", sending /register in that topic re-labels the old photos automatically.
Google Apps Script Code (`Code.gs`)
Auto-captures Telegram Display Name, Topic (`CS`, `Patrolling`, `Reports`, `Splash`), and Cairo Date/Time (`Africa/Cairo`)
/**
* Review | CS | Patrolling | Monitoring - Telegram Topic Photo Tracker
* Timezone: Africa/Cairo (Daily reset at 00:00 Midnight Cairo Time)
*
* 24/7 RECORDING MODE (use this — Telegram webhooks fail against Google Apps Script
* with "302 Found", because Google redirects and Telegram does not follow redirects):
* 1. Paste your bot token into BOT_TOKEN = '' below (or cell A2 of a "Config" sheet).
* 2. Run setup247Trigger ONCE from the editor toolbar and authorize Google.
* → A trigger then polls Telegram every 60 seconds, forever, saving every photo
* (name + topic + Cairo date + time) into the Logs sheet even while all
* browsers are closed. History is kept for at least 1 year.
*/
const TIMEZONE = 'Africa/Cairo';
const ALLOWED_TOPICS = ['CS', 'Patrolling', 'Reports', 'Splash', 'Emoji'];
const BOT_TOKEN = ''; // ← PASTE YOUR BOT TOKEN HERE between the quotes
function setupSheets() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let logs = ss.getSheetByName('Logs');
if (!logs) {
logs = ss.insertSheet('Logs');
logs.appendRow(['id', 'cairoDate', 'cairoTime', 'timestampIso', 'topic', 'displayName', 'username', 'userId', 'threadId']);
}
let roster = ss.getSheetByName('Roster');
if (!roster) {
roster = ss.insertSheet('Roster');
roster.appendRow(['id', 'name', 'username', 'topic', 'shift']);
}
let topicMap = ss.getSheetByName('TopicMap');
if (!topicMap) {
topicMap = ss.insertSheet('TopicMap');
topicMap.appendRow(['threadId', 'topicName']);
}
return { logs, roster, topicMap };
}
function getTopicMapDict(topicMapSheet) {
const rows = topicMapSheet.getDataRange().getValues();
const map = {};
for (let i = 1; i < rows.length; i++) {
if (rows[i][0] && rows[i][1]) map[String(rows[i][0])] = String(rows[i][1]);
}
return map;
}
// Bot token: ScriptProperties → Config sheet cell A2 → BOT_TOKEN constant
function readBotToken() {
const props = PropertiesService.getScriptProperties();
const saved = props.getProperty('botToken');
if (saved) return saved;
const cfg = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Config');
if (cfg) {
const v = String(cfg.getRange('A2').getValue() || '').trim();
if (v) { props.setProperty('botToken', v); return v; }
}
return String(BOT_TOKEN).trim();
}
// ═══ RUN THIS FUNCTION ONCE FROM THE TOOLBAR (Run ▶ button). Authorize when asked. ═══
function setup247Trigger() {
// Remove stale triggers of the same handler
ScriptApp.getProjectTriggers().forEach(function (tr) {
if (tr.getHandlerFunction() === 'pollTelegramUpdates') ScriptApp.deleteTrigger(tr);
});
ScriptApp.newTrigger('pollTelegramUpdates').timeBased().everyMinutes(1).create();
// Release any broken webhook so getUpdates is allowed
const token = readBotToken();
if (token) {
UrlFetchApp.fetch('https://api.telegram.org/bot' + token + '/deleteWebhook');
}
return '24/7 trigger installed — photos now auto-save every 60 seconds.';
}
// Called automatically every minute by the trigger. Fetches and stores ALL new
// photos/commands from the Telegram group, then confirms them so they are never re-read.
function pollTelegramUpdates() {
const token = readBotToken();
if (!token) throw new Error('No bot token yet: paste it into BOT_TOKEN at the top, or cell A2 of a Config sheet.');
const props = PropertiesService.getScriptProperties();
const offset = Number(props.getProperty('updateOffset') || 0);
let url = 'https://api.telegram.org/bot' + token + '/getUpdates?limit=100&timeout=0';
if (offset) url += '&offset=' + offset;
const res = UrlFetchApp.fetch(url);
const data = JSON.parse(res.getContentText());
if (!data.ok) throw new Error('Telegram API error: ' + String(data.description || ''));
let maxId = offset;
(data.result || []).forEach(function (upd) {
if (upd.update_id > maxId) maxId = upd.update_id;
const msg = upd.message || upd.edited_message || upd.channel_post;
if (msg) {
// Never let one odd message block the rest of the queue
try { handleTelegramMessage(msg); } catch (err) { console.error('Update skipped: ' + String(err)); }
}
});
if (maxId > offset) props.setProperty('updateOffset', String(maxId + 1));
}
// Rolling dedup cache so the same photo is never saved twice
function isDuplicateMsg(id) {
const props = PropertiesService.getScriptProperties();
let seen = [];
try { seen = JSON.parse(props.getProperty('seenIds') || '[]'); } catch (err) { seen = []; }
if (seen.indexOf(id) !== -1) return true;
seen.push(id);
if (seen.length > 300) seen = seen.slice(seen.length - 300);
props.setProperty('seenIds', JSON.stringify(seen));
return false;
}
// Shared handler: used by the 24/7 trigger poller (and optionally by a webhook doPost)
function handleTelegramMessage(msg) {
const { logs, roster, topicMap } = setupSheets();
const threadId = String(msg.message_thread_id || 'general');
const from = msg.from || {};
const senderName = [from.first_name || '', from.last_name || ''].join(' ').trim();
const displayName = senderName || from.username || ('User_' + (from.id || 'unknown'));
const username = from.username ? '@' + from.username : '';
// Allow "/register Splash" or "/register@BotName Splash" inside each topic
const rawText = (msg.text || msg.caption || '').trim();
if (rawText.toLowerCase().startsWith('/register')) {
const parts = rawText.split(/\s+/);
const requestedTopic = parts.slice(1).join(' ');
const matched = ALLOWED_TOPICS.find(t => requestedTopic.toLowerCase().includes(t.toLowerCase()));
if (matched) {
const dictNow = getTopicMapDict(topicMap);
if (dictNow[threadId] !== matched) topicMap.appendRow([threadId, matched]);
// Also ensure sender is added to Roster for this topic
const rRows = roster.getDataRange().getValues();
const exists = rRows.slice(1).some(r => String(r[3]) === matched && String(r[1]).toLowerCase() === displayName.toLowerCase());
if (!exists) {
roster.appendRow(['m_' + new Date().getTime(), displayName, username, matched, 'Registered via Bot']);
}
// Retroactively re-label photos that were saved earlier as 'Topic #id' for this same thread,
// so history recorded before /register is fixed automatically.
const logRows = logs.getDataRange().getValues();
for (let i = 1; i < logRows.length; i++) {
if (String(logRows[i][4]) === 'Topic #' + threadId) {
logs.getRange(i + 1, 5).setValue(matched);
}
}
}
return;
}
// Only count messages that contain a Photo (or image document)
const isPhoto = Boolean(msg.photo && msg.photo.length > 0) || Boolean(msg.document && msg.document.mime_type && msg.document.mime_type.startsWith('image/'));
if (!isPhoto) return;
// Resolve Topic Name: 1) saved TopicMap 2) reply-to topic creation 3) caption text 4) 'Topic #id' label
let topicName = getTopicMapDict(topicMap)[threadId] || '';
if (!topicName && msg.reply_to_message && msg.reply_to_message.forum_topic_created) {
const rawTitle = msg.reply_to_message.forum_topic_created.name || '';
const found = ALLOWED_TOPICS.find(t => rawTitle.toLowerCase().includes(t.toLowerCase()));
if (found) {
topicName = found;
topicMap.appendRow([threadId, found]);
}
}
if (!topicName && rawText) {
topicName = ALLOWED_TOPICS.find(t => rawText.toLowerCase().includes(t.toLowerCase())) || '';
}
if (!topicName) topicName = 'Topic #' + threadId;
// Convert timestamp to Cairo Time (Midnight 00:00 daily boundary)
const msgDate = msg.date ? new Date(msg.date * 1000) : new Date();
const cairoDate = Utilities.formatDate(msgDate, TIMEZONE, 'yyyy-MM-dd');
const cairoTime = Utilities.formatDate(msgDate, TIMEZONE, 'HH:mm:ss');
const id = 'tg_' + (msg.message_id || new Date().getTime());
if (isDuplicateMsg(id)) return;
logs.appendRow([id, cairoDate, cairoTime, msgDate.toISOString(), topicName, displayName, username, String(from.id || ''), threadId]);
// IMPORTANT for members who belong to MULTIPLE topics: save the sender separately
// under EACH topic they send in, so one person can be tracked in Splash + CS +
// Patrolling + Reports at the same time. The photo itself is logged for the topic
// it was sent in (topicName above), and the member gets a roster row for it.
const rRowsAll = roster.getDataRange().getValues();
const memberInThisTopic = rRowsAll.slice(1).some(r => String(r[3]) === topicName && String(r[1]).toLowerCase() === displayName.toLowerCase());
if (!memberInThisTopic) {
roster.appendRow(['auto_' + new Date().getTime() + '_' + Math.floor(Math.random() * 9999), displayName, username, topicName, 'Auto-Added from Photo']);
}
}
function doPost(e) {
try {
const payload = JSON.parse(e.postData.contents);
// Website Roster Sync POST (site roster → cloud)
if (payload.action === 'syncRoster' && Array.isArray(payload.roster)) {
const sheets0 = setupSheets();
sheets0.roster.clearContents();
sheets0.roster.appendRow(['id', 'name', 'username', 'topic', 'shift']);
payload.roster.forEach(m => {
sheets0.roster.appendRow([m.id, m.name, m.username || '', m.topic, m.shift || '']);
});
return ContentService.createTextOutput(JSON.stringify({ ok: true })).setMimeType(ContentService.MimeType.JSON);
}
// Telegram webhook update (fallback; the trigger poller is the primary 24/7 mode)
const msg = payload.message || payload.edited_message || payload.channel_post;
if (msg) handleTelegramMessage(msg);
return ContentService.createTextOutput('OK');
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ ok: false, error: String(err) })).setMimeType(ContentService.MimeType.JSON);
}
}
function doGet(e) {
const { logs, roster, topicMap } = setupSheets();
const logRows = logs.getDataRange().getValues().slice(1).map(r => ({
id: String(r[0]),
cairoDate: Utilities.formatDate(new Date(r[1]), TIMEZONE, 'yyyy-MM-dd').slice(0, 10) || String(r[1]),
cairoTime: String(r[2]),
timestampIso: String(r[3]),
topic: String(r[4]),
displayName: String(r[5]),
username: String(r[6]),
userId: String(r[7]),
threadId: String(r[8])
}));
const rosterRows = roster.getDataRange().getValues().slice(1).map(r => ({
id: String(r[0]),
name: String(r[1]),
username: String(r[2]),
topic: String(r[3]),
shift: String(r[4])
}));
return ContentService.createTextOutput(JSON.stringify({
ok: true,
logs: logRows,
roster: rosterRows,
topicMap: getTopicMapDict(topicMap)
})).setMimeType(ContentService.MimeType.JSON);
}