Runbook · autoSubscriLab

Lab Booking Automation

How the scheduled task claims the measurement-room slots on 實驗室場所借用表單: the weekend straight away, Tuesday and Friday only after asking the lab whether anyone needs them. It also fills in the equipment and room, and takes a slot back when somebody overwrites it.

Spreadsheet
實驗室場所借用表單
Writes as
autosubscrilab-sheet-writer@​protean-topic-445507-v7
Booking name
Sat/Sun 馬嘉鴻 · Tue/Fri by replies
Schedule
1 19 * * 5  (Fri 19:01)
Retry
every 600s until Fri 18:56
Reply window
3 days, or until the slot starts
Document date
2026-10-31

The task books the measurement room for the coming week. Saturday and Sunday are claimed at once under 馬嘉鴻. Tuesday and Friday are asked about first: everyone on the lab list gets a mail, and for each day the first answer decides it: a "no" books the day at once under that person's name, a "yes" keeps it free. With no "no" within three days, the day isn't booked.

The task starts from cron once a week, but it doesn't run only once. It keeps checking every ten minutes for the whole week. A slot held by somebody else today can be free tomorrow, the week tab it needs often doesn't exist yet when the task first fires, and the Tuesday/Friday answers come in over three days.

Everything below describes the system as it actually stands, verified against the live sheet and mail server rather than inferred from the code.

Block diagram: one run, Friday to Friday
Lab booking automation block diagram Cron starts book_week.sh on Friday at 19:01. Each pass books the weekend under 馬嘉鴻, asks the lab members about Tuesday and Friday on the first pass, decides each day by the first reply that answers it, writes the booked days to the Google Sheet, then combines the results: exit 0 stops, anything else sleeps ten minutes and retries. cron · Fri 19:01 book_week.shretry loop · every 10 min until next Fri 18:56 ① Ask (first pass only) ask_slots.py ask one mail per person · recorded in asks.json ② Decide (first answer wins) ask_slots.py decide per day · ≤ 3 days, before the slot · frozen first is yes, or no “no” skip · silence not counted first answer is no book at once ③ Write Tue/Fri set_week.sh --ref mails you when booked Weekend (every pass) set_week.sh --latest Sat, Sun · 馬嘉鴻 ④ Combine all done → 0 · still taken or waiting for a reply → 6 · error → 1 6 or error sleep 10 min, then the next pass 0 → stop mail the OK summary to you Lab members people.json · 15 addresses via SMTP mail.akiragather.com.tw Reply inbox IMAP, read-only mail.akiragather.com.tw Google Sheet 實驗室場所借用表單 Sheets API · claims.json ask mail replies ≤ 3 days read replies write cells, equipment, room 7012

Read it top to bottom. Each ten-minute pass runs the weekend block and the Tuesday/Friday column, then folds their results into one exit code. Blue blocks are outside the machine. The ask goes out once, the decision is made once, and only the writes repeat until every slot is settled. The sections below go through each block in detail.

1What each requirement maps to

Requirement → implementation
RequirementHow it is implementedLives in
Run at 7pm every Fridaycron entry starts the retry wrappercrontab → book_week.sh
Book 六 and 日, both 0800~1200 and 1200~1600--latest --only Sat,Sun under 馬嘉鴻, four cellsbook_week.sh
Ask everyone before booking 二 and 五one mail per person on the list, tagged [lab-slot <Monday>], sent once per weekask_slots.py ask
Don't book until someone says they won't use it, within 3 daysreplies read over IMAP every pass; the first answer for a day decides it, and a "no" books it at onceask_slots.py decide
A late "no" doesn't booka day's window ends after 3 days or when its 08:00 slot starts, whichever is first; after that the day is skippedask_slots.py
Tell me when a day is bookedone mail when the day is decided, another once the write has landedbook_week.sh
Always select the room option containing 7012room cell four rows below the name, matched against its own dropdownROOM_WANTED
Always select every equipment optionequipment cell two rows below the name, all 12 options joinedEQUIP_OFFSET
Detect somebody overriding the bookingcells previously seen holding the booked name are recorded in a claims fileclaims.json
Override it backa claimed cell showing another name is rewrittenplan_week.py
Email on run start / override / reclaim / Tue-Fri decisionfour event mails plus the end-of-run summarybook_week.sh

2Tuesday and Friday: ask first

On its first attempt each run, the wrapper has ask_slots.py mail every person in people.json, one mail each. The ask is recorded in asks.json, keyed by the week's Monday, so retries never send it twice. If the send fails, nothing is recorded and the next attempt tries again.

The ask mail

It is written to be polite and to say plainly what a reply leads to: a day answered "no" may be booked under that person's name, the slot is actually used by Carlos Ma (馬嘉鴻), that the first answer for a day decides it, and that not replying keeps their name off. Each day shows its own reply deadline. It also says the system is still being tested, and asks forgiveness for any booking it gets wrong. Chinese first, the same in English below. Dates and deadline are filled in per week; this is the text as the code generates it.

Subject: [lab-slot 〈Monday〉] 想請問下週 〈Tue〉(二)、〈Fri〉(五) 實驗室時段的使用意願 / Lab slots next week

〈name〉 你好,

不好意思打擾了。想請問你下週是否會用到實驗室的以下時段(0800~1200 與 1200~1600):
  〈Tue〉(二) 請於 〈deadline〉 前回覆
  〈Fri〉(五) 請於 〈deadline〉 前回覆

方便的話,請直接回覆這封信,例如:
  二: 要
  五: 不要

說明一下這些回覆會怎麼使用(每一天各自以最先收到的回覆為準):
  - 如果最先收到的是「要」,那天就會保留下來,請需要的人自行登記。
  - 如果最先收到的是你的「不要」,系統會立刻以你的名字登記這個時段,實際上這個時段會由馬嘉鴻 (Carlos Ma) 使用。
  - 在期限前都沒有人回覆「不要」的話,那天就不會登記。沒有回覆不列入計算,也不會用你的名字登記;所以如果不希望用你的名字登記,不回覆就可以了。

另外,這套自動登記系統目前還在測試階段,如果出了差錯而登記錯誤,還請多多包涵,也歡迎直接告訴我,我會儘快修正。

謝謝你撥空回覆!
馬嘉鴻

---
Hi 〈name〉,

Sorry to bother you. Could you let me know whether you will need the lab
next week on these days (0800~1200 and 1200~1600)?
  Tue 〈Tue〉   please reply by 〈deadline〉
  Fri 〈Fri〉   please reply by 〈deadline〉

If you can, please just reply to this mail, e.g.
  Tue: yes
  Fri: no

How the replies are used (for each day, the first answer received
decides it):
  - If the first answer is yes, that day is kept free for them to book.
  - If the first answer is your no, the system books that slot under
    your name right away, and the slot will actually be used by
    Carlos Ma (馬嘉鴻).
  - If nobody answers no by the deadline, the day is not booked. No
    reply is not counted, and your name will not be used. So if you
    would rather not have your name on a booking, simply don't reply.

Please note this booking system is still being tested. If something
goes wrong and a slot is booked by mistake, please forgive me, and do
let me know so I can put it right.

Thank you for taking the time!
Carlos (馬嘉鴻)

From then on, every pass reads the replies and looks at each undecided day on its own. The first reply that answers a day decides it, at once and for good. Each day has its own window: three days from the ask, but never past 08:00 on that day, since a slot that has started isn't worth asking about any more. While a day is undecided it counts as not yet booked (exit 6), which keeps the retry loop alive.

Per-day decision: the first answer wins, and is frozen
For that dayActionBooked under
the first answer is yesskip— left for them to book themselves; a later "no" changes nothing
the first answer is nobookthat person, at once; a later "yes" is too late
no answer before the window endsskip— no reply is not counted, and a "no" after the window is too late

Booked days go through the same planner as the weekend, with --ref set to the run's start date so they land on the week that was asked about, never whichever tab happens to be newest. The name written is the person who said no first. If the cell's dropdown doesn't contain that name (exit 5), the write fails on every pass and the day stays unbooked, so names in people.json must match the sheet's.

Which replies count

Reply matching
RuleDetail
Read without touchingIMAPS to mail.akiragather.com.tw:993, INBOX opened read-only, bodies fetched with PEEK so nothing is marked read
Who is answeringa reply to a person's own ask mail (each has its own Message-ID) counts as theirs, from whatever address or alias it comes; otherwise the sender must be an asked address and the subject must carry lab-slot <Monday>
Inside the windowserver arrival time between the ask and that day's deadline; a late reply is ignored
First answer winsreplies are taken in arrival order; the first one that answers a day decides it, whoever sent it
Own words onlyquoted text (> lines, On … wrote:, 寫道, forwarded headers) is cut off before parsing, and so is the signature, from a sign-off like best regards, -- or 謝謝 onwards
No beats yes不要 / 不用 / no is checked before 要 / 會 / yes, so 不要 never reads as a yes
Day per linea line naming 二 / 週二 / Tue / the date answers that day; a short line with no day (no thanks, 不用,謝謝) answers both
Unclear = no answera reply that gives no yes or no for a day counts as no reply for that day, and so is not counted

Booking under someone else's name. A "no" means that person won't use the slot, not that their name may go on it. Their name, not 馬嘉鴻, is what the rest of the lab sees on the sheet. People who don't reply are never used. The "decided" and "booked" mails say exactly whose name each day went under.

3How the sheet is shaped

Almost nothing about the grid is hardcoded, and that is deliberate: the sheet is edited by a couple of dozen people and its shape moves. Only the spreadsheet id is fixed. Everything else is located by reading the sheet each run.

Each week lives on its own tab, titled with its date range, like 11/2~11/8. Within a tab, column E carries the time-slot labels and row 3 carries the day headers. A slot occupies a twelve-row block holding two independent booking groups of six rows each, and inside a group the rows have fixed roles:

Booking group layout, relative to the name row
RowHoldsDropdown sourceWhat gets written
Nthe person booking'<week tab>'!B3:B100馬嘉鴻, or the chosen Tue/Fri name
N + 2equipment'函式運算庫'!A17:A28all 12 options, joined with ", "
N + 4room'函式運算庫'!D17:D207012

Why the day columns are matched, never counted

The day columns are not in calendar order. On the current tab they run Mon, Tue, Wed, Fri, Sat, Sun across F to K, with a 特殊時段 column at L and Thursday stranded out at M. Counting columns would land on the wrong day silently and book somebody else's afternoon. So the script finds a day by searching row 3 for the (二), (五), (六) or (日) marker instead.

Row 3 header map, tab 11/2~11/8
ColumnFGHIJKLM
Header 11/2(一)11/3(二)11/4(三)11/6(五) 11/7(六)11/8(日)特殊時段11/5(四)

The same reasoning applies to values. Every value written is read back out of the target cell's own dropdown before use, so a near-miss like 馬家鴻 for 馬嘉鴻 fails loudly instead of being written and silently ignored by whoever reads the sheet. The dropdown holds sheet names, which aren't always the names on the mail list: it has Chinu and MD, for instance.

4What happens to each target cell

Every pass considers the four weekend cells and, once decided, the booked Tuesday/Friday cells. The rule that decides each one is deliberately narrower than a blanket overwrite: a slot is only taken from somebody if it was ours first.

Decision table
Cell currently showsRecorded in claims?ActionResult
No one—claim it, record the claimwrite
the name being booked (馬嘉鴻)—leave it, record the claim, fix equipment and roomno-op
the name being booked (Tue/Fri proxy)nothat person's own booking: counts as done, nothing touched, no claimtheirs
somebody elseyestake it back, then mailreclaim
somebody elsenoleave alone, retry in 10 minskip
blank, or 碩一預留noleave alone — not demonstrably freeskip

The proxy row exists so that a person who booked a slot themselves before the decision doesn't have their equipment selection rewritten, or their slot "taken back" in their own name later. The wrapper turns it on with BOOK_WEEK_PROXY=1 for Tuesday/Friday calls only.

A cell that holds neither the booked name nor No one is never assumed to be available. A blank cell in particular is ambiguous: it may be a slot nobody has touched, or one somebody cleared on their way to filling it in. So it is left alone. The --force flag overrides all of this, and the scheduled task never passes it.

5One pass, step by step

A single attempt
#StepDetailOn failure
1Weekendset_week.sh --latest --only Sat,Sun under 馬嘉鴻see exit codes
2Askask_slots.py ask: mails the list the first time, a no-op afterretry next pass
3Decideask_slots.py decide: one line per day, book, skip or pending; a failed IMAP read leaves undecided days pendingpending → 6
4Tue/Friskip, or set_week.sh --ref <run date> --only Tue <name>; mail you once it is writtensee exit codes
5Combineany real failure → that code; else any still-taken → 6; else 0—
6Loop0 → stop and mail OK. Otherwise sleep 600s and go again.—

Each set_week.sh call is itself the same pipeline as before: service-account token, choose the tab, fetch A1:M100 plus the library tab with dropdown rules, plan, one batchUpdate, and promote the claims only after the write returns 200.

Exit codes
CodeMeaningWrapper's response
0every target slot is donestop, mail the summary
6a slot is still somebody else's, or a Tue/Fri day is still waiting for an answerkeep retrying — not an error
2no matching week tab (e.g. next week's not created yet)keep retrying
3two tabs claim the same week, refusing to guesskeep retrying; pin one with --gid
5a name is not in the cell's dropdownkeep retrying; needs a human (fix the name in people.json)
1auth, network, API or mail failurekeep retrying

Exit 6 deserves emphasis: it is the normal state, not a fault. It means the task did everything it could this minute and intends to come back. Two days of exit 6 while the lab answers, followed by a single exit 0, is the system working exactly as designed.

6The state files

Telling "somebody took my slot" apart from "this slot was never mine" needs memory, and claims.json is it. It records, per tab, the cells that have been observed holding the booked name:

{
 "11/2~11/8": [ "G16", "I16", "I28", "J16", "J28", "K16", "K28" ]
}

asks.json is the memory for the Tuesday/Friday question. It holds one entry per week: when the ask went out, the deadline, who was asked, the Message-IDs replies are matched against, and, once made, the parsed answers and the decision. Both it and people.json hold personal email addresses and are gitignored.

{
 "2026-11-09": {
  "ref": "2026-11-06", "sent_at": …, "deadline": …,
  "asked": [ … ], "message_ids": [ … ],
  "decision": {
   "Tue": {"action": "book", "names": ["鍾毓星"], "answered": "no",
           "at": "2026-11-07 09:12:40"},
   "Fri": {"action": "skip", "names": [], "answered": null,
           "at": "2026-11-09 19:01:00"} }
 }
}
Safeguards
RulePrevents
Claims are promoted only after the write returns HTTP 200a failed write leaving a claim we do not hold, which would evict somebody on a later pass
Dry runs never write the claims file, and ask --dry-run sends nothinga rehearsal quietly changing live state
Claims merge rather than replacean --only Sun run making the system forget Saturday
The ask is recorded only once the mails are senta failed send leaving the week marked asked with nobody asked
Each day's decision is frozen once madea later reply flipping a day that is already booked or left free
Someone's own booking is left untouchedrewriting the equipment of a person who booked a slot for themselves
A steal is mailed once per episode, not once per retrya slot that cannot be reclaimed mailing every ten minutes for a week
Mail failures are logged, never fatala mail outage stopping the booking itself

7Which tab each part targets

The weekend uses --latest, the newest week tab. Next week's tab is often created only a few days ahead, so the current week keeps being checked until it appears, and then the retries move onto it without anyone intervening.

Tuesday and Friday use --ref instead: the week starting the Monday after the run began, the same week the mail asked about. If that tab doesn't exist yet the pass exits 2 and retries. A decision about 11/10 can never be written onto 11/3.

8What arrives in your inbox

Mail events
SubjectToSent whenCarries
[book_week] run startingyouthe wrapper starts — Friday 19:01, or a manual runretry interval and the deadline
[lab-slot <Monday>] 想請問下週 … 實驗室時段的使用意願 / Lab slots next weekeach person on the listfirst attempt of the runthe two dates, how to reply and by when, and what a yes, a no or no reply leads to
[book_week] Tue decided: …youthe pass that first sees a day decided, once per daybook under whom (who said no first), or skip and why
[book_week] Tue booked under …youthe write for that day has landed (HTTP 200), once per daytime, day, name, and the cells written
[book_week] someone overrode your bookingyoua claimed cell turns up in another namethe cell, day, slot, and who holds it
[book_week] took your booking backyouthat cell is rewritten and the write confirmsthe same detail lines
[book_week] OK / INCOMPLETE / FAILyouthe retry loop finally exitsthe full output of the last attempt

9The week, end to end

Timeline
WhenWhat happens
Fri 19:01cron starts the wrapper; "run starting" mail; weekend claimed; ask mails go out for next Tue/Fri
every 10 minweekend re-checked (claim, reclaim, or wait); replies read: a first "no" books that day at once, a first "yes" keeps it free; you get a mail either way
Mon ~19:013-day window closes (earlier for a day whose 08:00 slot comes first); any day still unanswered is skipped
every 10 minall booked slots re-checked until every one is done
Fri 18:56the loop stops five minutes early, so two instances never overlap
Fri 19:01cron starts the next one

The first run with the ask step is Fri 6 Nov 19:01, asking about Tue 11/10 and Fri 11/13, with replies counted until Mon 9 Nov 19:01. The run started 30 Oct still uses the old flow, booking Tue/Fri under 馬嘉鴻, until it ends 6 Nov 18:56.

10Operating it by hand

Commands
IntentCommand
See what a pass would do, change nothing./set_week.sh --dry-run --latest
Preview the ask mail, send nothingpython3 ask_slots.py ask $(date +%F) --dry-run
See this week's Tue/Fri decisionsource smtp_credentials.sh; python3 ask_slots.py decide <run date>
Run one pass now./set_week.sh --latest
Work on one specific tab./set_week.sh --gid 1790236012
Only one day./set_week.sh --latest --only Sun
Take a slot that was never ours./set_week.sh --latest --force
Change who gets askededit people.json (name, email)
Watch it worktail -f book_week.log
Stop the retry loopkill <pid of book_week.sh>

--dry-run is safe in the strongest sense: it prints the exact request body it would have posted, and touches neither the sheet nor the claims file. Passed to book_week.sh, it reaches every set_week.sh call, but not the ask mail. Preview that with ask --dry-run.

11Where things stood on 31 Oct

Tab 11/2~11/8
SlotNameEquipmentRoomState
Tue 0800~1200G16 馬嘉鴻G18 all 12G20 7012held
Tue 1200~1600G28 林振宇——not ours
Fri 0800~1200I16 馬嘉鴻I18 all 12I20 7012held
Fri 1200~1600I28 馬嘉鴻I30 all 12I32 7012held
Sat 0800~1200J16 馬嘉鴻J18 all 12J20 7012held
Sat 1200~1600J28 馬嘉鴻J30 all 12J32 7012held
Sun 0800~1200K16 馬嘉鴻K18 all 12K20 7012held
Sun 1200~1600K28 馬嘉鴻K30 all 12K32 7012held

This week was booked by the old flow. G28 is the case the reclaim rule deliberately does not cover: it has never been ours, so it is left alone, and the run keeps exiting 6 on it until the week ends.

Ask and reply, tested live

Run from a sandbox copy with 馬嘉鴻 as the only person asked, so the real asks.json and the Friday run were untouched.

Test mails, 31 Oct
MailReplyRead asFix it led to
first wordingyes i will use tuesday 08-12, sent from chiahungma@mail.akiragather.com.twat first: ignored; then Tue yes, Fri nothe ask went to chma@akiragather.com.tw, so the reply was dropped: replies are now matched by the ask mail they answer. The stray "no" came from "No. 70" in the signature: signatures are now cut, and a no with no day only counts on a short line
polite wording, before the Carlos Ma line— (superseded)——
polite wording, with the Carlos Ma line我不會使用週五0800-1200 and 1200-1600, from the alias addressTue skip no answer · Fri book said nonone: matched by Message-ID, signature cut, 不會 + 週五 read as Friday no

Two things to keep in view. The retry loop wakes every ten minutes, so reclaiming is effectively instant and repeats indefinitely. If a slot changes hands by agreement rather than by accident, stop the loop rather than let it fight.

The sheet's own instructions ask for one slot at a time (two for doctoral students) and say time exchanges need both parties to agree. Holding seven half-day slots, or slots in other people's names, sits outside that.

12Open items

Status
ItemStatusConsequence
SMTP loginresolvedSMTP_HOST is mail.akiragather.com.tw, the name on the STARTTLS certificate; start, steal, reclaim and summary mails all tested delivered
IMAP for repliesresolvedport 993 presents the mail.akiragather.com.tw certificate, so IMAP_HOST is set to that; login verified, and a mail to the sending address confirmed to land in the INBOX that is read
Chinmaya Tripathy → Chinuresolvedrenamed to the sheet's Chinu in people.json and in the 31 Oct ask record, so a first "no" from Chinu books under a name the dropdown accepts (the mail already sent still greets them as Chinmaya Tripathy). M.D has no email and isn't asked; 馬嘉鴻 isn't asked either
Reply parsingtested liveunit-tested on typical Chinese and English replies, and tested end to end on 31 Oct (see section 11). A free-form reply it can't read counts as no reply, and is not counted, so check the decision mail the first few weeks
Credentials committed to gitexposedthe service-account key, OAuth client secret and SMTP file have been in the repository history since 29 Jul and are on the remote; needs untracking plus rotation
Chrome profile for the browser helperunverifiedstill set to the default profile; worth confirming the account it pins is signed in there

13File reference

What each file does
FileRole
book_week.shthe scheduled entry point: retry loop, weekend + Tue/Fri passes, logging, all status mail
ask_slots.pyask mails the list; decide reads replies over IMAP and prints each day's decision
set_week.shone pass — token, tab, fetch, plan, write
plan_week.pyall the cell decisions: tab choice, targets, dropdowns, claims, request body
sa_token.shservice-account JWT → access token
send_mail.pySMTP sender over verified STARTTLS; CLI with --html and --attach FILE, plus a send() used by ask_slots.py
smtp_credentials.shSMTP and IMAP settings, sourced by the wrapper (gitignored, chmod 600)
people.jsonwho is asked, in priority order (gitignored)
asks.jsonper-week ask record and each day's frozen decision (gitignored)
claims.jsoncells seen holding the booked name, per tab
book_week.logevery attempt, appended
set_cell.shstandalone single-cell writer, kept for manual use
authorize.sh, access_token.shthe user-OAuth path — present but unused