แจ้งเตือนถี่เกินเท่ากับไม่ได้แจ้ง จูนบอทให้คนอ่านจริง
การตรวจจับถูกทุกครั้ง แต่คนอ่านเลิกอ่านไปแล้ว ปัญหาของระบบแจ้งเตือนส่วนใหญ่ไม่ได้อยู่ที่ความแม่นยำ มันอยู่ที่จำนวนข้อความต่อวันที่ไม่มีใครยอมรับไหว
พอดีผมทำระบบเฝ้ามอนิเตอร์เครื่องทำความเย็นตัวหนึ่ง อ่านค่ากระแสจากมิเตอร์เก็บลงฐานข้อมูล แล้วให้สคริปต์คอยดูแล้วยิงข้อความเข้าแอปแชตเมื่อมีเหตุการณ์ ตอนแรกทุกคนชอบ พอผ่านไปสองสัปดาห์ คนรับเริ่มบ่นว่ามันมั่ว ผมเลยนั่งไล่ล็อกของวันเดียวออกมาดูทั้งวัน แล้วพบว่าการตรวจจับไม่ได้มั่วเลยสักข้อ ทุกเหตุการณ์ถูกหมด สิ่งที่มั่วคือจำนวนข้อความ ผมเลยเก็บวิธีจูนไว้เป็นชุด เพราะมันใช้ได้กับระบบแจ้งเตือนทุกแบบ ไม่ใช่แค่งานเครื่องทำความเย็น
ส่วนเรื่องฟิสิกส์ของการอนุมานว่าเครื่องกำลังละลายน้ำแข็งอยู่หรือเปล่า ผมเขียนไว้แล้วใน อนุมานจังหวะละลายน้ำแข็งจากความชันอุณหภูมิ ตอนนี้ว่าด้วยเรื่องเดียวคือ พอตรวจจับถูกแล้ว จะส่งอะไรให้ใครบ้าง
นับก่อนแก้ วันเดียว 28 เหตุการณ์กลายเป็น 56 ข้อความ
สิ่งแรกที่ผมทำไม่ใช่แก้โค้ด แต่คือนับ ผมดึงล็อกของวันที่คนบ่นออกมาแล้วนับตามชนิดเหตุการณ์ ถ้าไม่ทำขั้นตอนนี้ก่อน เราจะเดาว่าตัวการคือเหตุการณ์ใหญ่ๆ ทั้งที่จริงตัวการมักเป็นของเล็กๆ ที่ยิงซ้ำ
# นับเหตุการณ์ที่ยิงออกไปในวันเดียว แยกตามชนิด
grep "^$(date +%F)" /var/log/example-notify.log \
| grep -oE '\[event\] [A-Z-]+' | sort | uniq -c | sort -rn
# นับจำนวนข้อความจริงที่ออกไป (หนึ่งเหตุการณ์ส่งหลายคนได้)
grep -c '\[send\] ok=True' /var/log/example-notify.log
ตัวเลขนี้เปลี่ยนวิธีคิดทั้งหมด เพราะเหตุการณ์ที่มีค่าจริงคือเปิดเครื่อง ปิดเครื่อง และรอบละลายน้ำแข็งห้ารอบ รวมสิบสองเหตุการณ์ ส่วนอีกสิบหกเหตุการณ์เป็นการเตือนซ้ำหลังละลายน้ำแข็งจบ ซึ่งบอกแค่ว่าเครื่องเดินมากี่นาทีแล้ว และมันคือ 57 เปอร์เซ็นต์ของทั้งหมด พูดง่ายๆ คือคนรับต้องอ่านข้อความที่ไม่มีข้อมูลใหม่มากกว่าครึ่งของทุกวัน แล้วเขาก็เลิกอ่านทั้งหมดรวมทั้งอันที่สำคัญด้วย
แยกชั้นผู้รับก่อน ลดจำนวนข้อความได้ครึ่งหนึ่งโดยไม่ต้องแก้ตรรกะ
จุดที่ได้ผลเร็วที่สุดคือเลิกส่งทุกอย่างให้ทุกคน ระบบเดิมมีผู้รับสองคนแล้วส่งเหมือนกันทุกข้อความ ซึ่งผิดตั้งแต่ต้น เพราะสองคนนี้ต้องการคนละอย่าง คนคุมหน้างานต้องรู้ทุกจังหวะเพราะเขาต้องเดินไปดูเครื่อง ส่วนเจ้าของกิจการต้องการรู้แค่ว่าวันนี้เครื่องเปิดกี่โมง ปิดกี่โมง และสรุปตอนจบรอบว่ายังไง
# ผู้รับแยกชั้น: (ปลายทาง, สไตล์ข้อความ, ระดับที่รับ)
TIER_ALL = {"on", "off", "defrost_start", "defrost_end", "reminder", "runtime"}
TIER_BRIEF = {"on", "off", "defrost_end"} # เจ้าของ: เปิด ปิด และสรุปจบรอบ
RECIPIENTS = [
("site-lead", "casual", TIER_ALL),
("owner", "formal", TIER_BRIEF),
]
def notify(kind, build):
"""build(style) -> ข้อความตามสไตล์ของผู้รับแต่ละคน"""
for target, style, tier in RECIPIENTS:
if kind not in tier:
continue
try:
send_message(target, build(style))
except Exception as e:
log("[notify] %s: %s" % (target, e))
ทุกคนได้ทุกอย่าง
- 28 เหตุการณ์ คูณ 2 คน เท่ากับ 56 ข้อความ
- เจ้าของได้การเตือนซ้ำ 16 ครั้ง
- คนที่ไม่ได้ทำอะไรกับข้อมูล ก็ต้องอ่าน
- สุดท้ายทั้งสองคนปิดการแจ้งเตือน
คนละชุดตามหน้าที่
- คนคุมหน้างานได้ครบ เพราะต้องเดินไปดู
- เจ้าของได้ เปิด ปิด สรุปจบรอบ รวม 7 ข้อความ
- ปริมาณรวมลดจาก 56 เหลือ 35 ทันที
- ไม่ได้แตะตรรกะตรวจจับเลยแม้แต่บรรทัดเดียว
การแยกชั้นยังพ่วงเรื่องสำนวนข้อความมาด้วย ผมให้ฟังก์ชันสร้างข้อความรับ style แล้วคืนคนละสำนวน เพราะคนคุมหน้างานอ่านแบบสั้นห้วนได้ ส่วนเจ้าของอยากได้แบบสุภาพเป็นทางการกว่า จุดสำคัญคือมันเป็นฟังก์ชันเดียวกัน ไม่ใช่โค้ดคนละชุด ไม่งั้นแก้ที่หนึ่งลืมอีกที่แน่นอน
การเตือนซ้ำ ลดจากหกครั้งเหลือครั้งเดียว
ตัวการหลักคือบล็อกเตือนซ้ำหลังละลายน้ำแข็งจบ ที่ตั้งไว้ว่ายิงทุก 30 นาที สูงสุด 6 ครั้ง เจตนาเดิมดี คืออยากรู้ว่าหลังละลายน้ำแข็งแล้วห้องกลับมาเย็นทันหรือเปล่า แต่ในทางปฏิบัติ ข้อความครั้งที่สองถึงครั้งที่หกไม่ได้เพิ่มข้อมูลอะไร มันบอกแค่ว่าเวลาผ่านไปอีกสามสิบนาที
ON_REPORT_EVERY = 1800 # วินาที: จังหวะรายงานระหว่างเครื่องเดิน
PD_INTERVAL = 1800 # วินาที: จังหวะเตือนซ้ำหลังละลายน้ำแข็งจบ
PD_MAX = 1 # เดิม 6 -> เหลือ 1 (สรุปหนึ่งครั้งพอ)
now = int(time.time())
if S["state"] == "ON" and not S["def_active"] and S.get("pd_end") and now >= S.get("pd_next", 0):
stat = read_status(db)
mins = max(1, round((now - S["pd_end"]) / 60))
notify("reminder", lambda style: msg_post_defrost(mins, stat, style))
S["pd_count"] += 1
S["pd_next"] = now + PD_INTERVAL
if S["pd_count"] >= PD_MAX:
S["pd_end"] = 0 # ปิดชุดนี้ รอรอบละลายน้ำแข็งครั้งหน้า
save_state()
ระบบแจ้งเตือนที่ยิงซ้ำจนกว่าสถานะจะกลับปกติ ฟังดูสมเหตุสมผลบนกระดาษ แต่ในความจริงมันคือกรณีที่สร้างข้อความมากที่สุดในวันที่แย่ที่สุด ซึ่งเป็นวันที่คนรับต้องการสมาธิที่สุดพอดี ผมตั้งเพดานไว้เสมอ แล้วให้ข้อความสุดท้ายในชุดบอกตรงๆ ว่า นี่คือครั้งสุดท้ายของเรื่องนี้ ถ้าอยากรู้สถานะให้ถามกลับมา คนรับจะได้รู้ว่าความเงียบต่อจากนี้ไม่ได้แปลว่าระบบตาย
อีกจุดหนึ่งที่ผมเพิ่มคือ รายงานระหว่างเครื่องเดินกับการเตือนซ้ำหลังละลายน้ำแข็ง ต้องไม่ทับกัน เดิมทั้งสองตัวมีนาฬิกาของตัวเอง แล้วบางช่วงมันตรงกันพอดี คนรับเลยได้สองข้อความติดกันที่พูดเรื่องเดียวกัน วิธีแก้คือให้ตัวหนึ่งเลื่อนนาฬิกาของอีกตัวทุกครั้งที่ยิง S["on_next"] = now + ON_REPORT_EVERY ทำให้สองตัวไม่มีวันชนกัน
รอบที่ซ้อนกัน ต้องรวมเป็นรอบเดียว ไม่ใช่นับสองรอบ
ในล็อกวันนั้นมีรอบละลายน้ำแข็งสองรอบห่างกันแค่ 8 นาที ตอน 10:12 กับ 10:27 ซึ่งดูแล้วไม่สมเหตุสมผล พอไล่ค่ากระแสจริงถึงเข้าใจ คือรอบแรกฮีตเตอร์ดับไปประมาณ 7 นาทีแล้วติดกลับมาใหม่ ซึ่งเป็นพฤติกรรมปกติของเครื่อง แต่ค่ารอที่ผมตั้งไว้ตอนนั้นคือ 120 วินาที เจ็ดนาทีมากกว่าสองนาที ระบบเลยฟันไปแล้วว่ารอบแรกจบ พอฮีตเตอร์ติดใหม่ก็นับเป็นรอบที่สอง กลายเป็นข้อความเริ่มและจบสองชุดติดกัน
DEF_THR = 100 # เกณฑ์กระแสฮีตเตอร์ ที่ถือว่าเป็นการละลายน้ำแข็งจริง
DEF_END_GRACE = 900 # เดิม 120 วินาที -> 900 วินาที (ใกล้เคียงความยาวรอบปกติ)
if heater_current > DEF_THR:
if not S["def_active"]:
S["def_active"] = True
S["def_start"] = ts
S["pd_end"] = 0 # รอบใหม่ ยกเลิกชุดเตือนซ้ำของรอบก่อน
S["def_off_since"] = 0 # ฮีตเตอร์กลับมา = ยังเป็นรอบเดิม ไม่ split
S["def_last"] = ts
elif S["def_active"]:
if not S.get("def_off_since"):
S["def_off_since"] = ts
if ts - S["def_off_since"] >= DEF_END_GRACE:
S["def_active"] = False
S["ndef"] += 1
notify("defrost_end", lambda style: msg_defrost_end(S, style))
S["pd_end"] = S["def_last"] # เริ่มนับชุดเตือนซ้ำจากตรงนี้
S["pd_next"] = S["def_last"] + PD_INTERVAL
S["pd_count"] = 0
จุดที่ต้องระวังคือ เวลาสรุปความยาวรอบ ต้องนับถึง ครั้งสุดท้ายที่เห็นฮีตเตอร์ทำงาน ไม่ใช่นับถึงเวลาที่ตัดสินว่าจบ เพราะไม่งั้นทุกรอบจะยาวเกินจริงไป 15 นาทีเท่ากับค่ารอที่ตั้งไว้ ผมเลยเก็บ def_last ไว้แยกจากเวลาที่ตัดสิน แล้วรายงานจาก def_last เสมอ
ผมเลือก 900 วินาทีเพราะไปดูข้อมูลย้อนหลังแล้วพบว่ารอบละลายน้ำแข็งปกติของเครื่องตัวนี้ยาวประมาณ 5 ถึง 19 นาที ค่ารอที่ใกล้เคียงความยาวรอบปกติ ทำให้รอบที่ติดกันถูกรวมเป็นรอบเดียวโดยอัตโนมัติ ข้อแลกเปลี่ยนคือข้อความสรุปจะมาช้าลงประมาณ 15 นาทีหลังรอบจบจริง ซึ่งยอมรับได้ เพราะข้อความสรุปไม่ใช่ข้อความที่ต้องรีบ ต่างจากข้อความเปิดเครื่องที่ต้องมาทันที
สถานะต้องรอดจากการรีสตาร์ต ไม่งั้นการนับเริ่มใหม่ทุกครั้ง
ในไทม์ไลน์วันนั้นมีการรีสตาร์ตบริการกลางวันด้วยตอน 19:43 แล้วชุดเตือนซ้ำนับต่อจากเดิมได้ถูกต้อง เพราะสถานะทั้งหมดถูกเขียนลงไฟล์ ไม่ได้อยู่แค่ในหน่วยความจำ ถ้าไม่ทำแบบนี้ พอรีสตาร์ตเมื่อไหร่ ตัวนับกลับเป็นศูนย์ แล้วคนรับก็จะได้ชุดเตือนซ้ำใหม่อีกหกครั้งทั้งที่เพิ่งได้ไปแล้ว
STATE = "/var/lib/example/notify.state"
S = {"state": "OFF", "last_ts": 0, "on_since": None, "day_start": 0, "ndef": 0,
"last_active": 0, "def_active": False, "def_start": 0, "def_last": 0,
"def_off_since": 0, "pd_end": 0, "pd_next": 0, "pd_count": 0, "on_next": 0}
def load_state():
try:
S.update(json.load(open(STATE)))
except Exception:
pass # ไฟล์ยังไม่มี = เริ่มจากค่าตั้งต้น ไม่ใช่ข้อผิดพลาด
def save_state():
try:
json.dump(S, open(STATE, "w"))
except Exception as e:
log("[state] %s" % e)
มีอีกข้อที่สำคัญไม่แพ้กัน คือตอนเริ่มทำงานครั้งแรกหลังรีสตาร์ต ต้อง อ่านค่าปัจจุบันมาตั้งสถานะแล้วเงียบไว้ ห้ามยิงข้อความย้อนหลัง เพราะไม่งั้นทุกครั้งที่รีสตาร์ตในขณะเครื่องเดินอยู่ คนรับจะได้ข้อความเปิดเครื่องอีกรอบ ทั้งที่เครื่องเปิดมาตั้งแต่เช้า ผมเขียนบรรทัดล็อกกำกับไว้ตรงนี้ตรงๆ ว่าเริ่มแบบเงียบ เพื่อให้ตัวเองอ่านล็อกทีหลังแล้วรู้ว่าช่วงนั้นไม่มีข้อความเพราะอะไร
ยกเลิกอัตโนมัติเมื่อเครื่องดับ แต่ให้ข้ามรอบ ไม่ใช่ยกเลิกทั้งชุด
ปัญหาที่ตามมาหลังตั้งชุดเตือนซ้ำคือ ถ้าเครื่องถูกปิดระหว่างที่ชุดยังไม่หมด ระบบจะยิงข้อความว่าเครื่องทำงานมากี่นาทีแล้วต่อไป ทั้งที่ตู้ดับไปแล้ว ทางแก้คือก่อนยิงทุกครั้งให้ดูค่ากระแสจริงก่อน ถ้าไม่มีกระแสไหลเลยก็ไม่ต้องยิง
แต่ตรงนี้ผมเคยแก้ผิดมาแล้วรอบหนึ่ง ตอนแรกผมสั่งให้ยกเลิกชุดทั้งชุดเมื่อเจอกระแสเป็นศูนย์ ผลคือเวลาคอมเพรสเซอร์ตัดตามเทอร์โมสตัทตามปกติ ซึ่งกระแสเป็นศูนย์ชั่วคราวสองสามนาที ชุดรายงานก็หายไปทั้งชุด แล้วคนรับบ่นว่ารายงานไม่ครบ ผมเลยเปลี่ยนเป็น ข้ามรอบนี้แล้วรอรอบหน้า แทน
running = (stat.get("comp", 0) > COMP_THR) or (stat.get("heat", 0) > 0) \
or (stat.get("line", 0) > 0)
if not running:
# คอมตัดชั่วคราวตามเทอร์โมสตัท = ข้ามรอบนี้พอ ห้ามยกเลิกทั้งชุด
# ถ้าเครื่องดับจริง ตัวตรวจจับ OFF จะเคลียร์ pd_end ให้เองอยู่แล้ว
S["pd_next"] = now + PD_INTERVAL
save_state()
log("[pd] ไม่มีกระแสไหล ข้ามรอบนี้ รอรอบหน้า")
else:
...ยิงข้อความตามปกติ...
หลักคิดคือ ให้ตัวตรวจจับสถานะเป็นคนตัดสินใจว่าจบหรือไม่จบ ส่วนตัวยิงข้อความมีหน้าที่แค่ตัดสินใจว่ารอบนี้ควรยิงหรือไม่ ถ้าเอาสองหน้าที่นี้มาปนกันเมื่อไหร่ ก็จะได้พฤติกรรมแปลกๆ แบบที่ผมเจอ
ห้ามใส่ keepalive หรือ poll ถี่กับ session ของแอปแชต
ข้อนี้สำคัญที่สุดในบทความ และเป็นข้อที่คนแก้ผิดทางกันมากที่สุด อาการที่เจอคือคนรับบ่นว่าข้อความมาช้าประมาณหนึ่งนาที คนส่วนใหญ่จะแก้ด้วยการเพิ่มการเช็คสถานะการเชื่อมต่อให้ถี่ขึ้น หรือเติม keepalive เข้าไป เพราะคิดว่าปัญหาอยู่ที่การเชื่อมต่อหลับ
ฝั่งบริการแชตมีระบบป้องกันการใช้งานผิดปกติของตัวเอง การเรียกดูสถานะหรือดึงโปรไฟล์ซ้ำๆ ในความถี่สูง ถูกนับเป็นพฤติกรรมผิดปกติ ผลที่ผมเจอคือ session ค้างไปเฉยๆ ทั้งที่บริการยังทำงานอยู่และตอบสถานะว่าปกติ ข้อความที่ยิงในช่วงนั้นหายเงียบโดยไม่มีข้อผิดพลาดใดๆ ให้จับ ซึ่งแย่กว่าข้อความมาช้าหนึ่งนาทีมาก อย่าไปแตะฝั่งการเชื่อมต่อเพื่อแก้ปัญหาความหน่วง ให้ปล่อยการทำงานตามปกติเลี้ยงการเชื่อมต่อไปเอง แล้วไปลดความหน่วงที่ฝั่งของเราแทน
ความหน่วงหนึ่งนาทีนั้น พอผมไล่ดูจริงมันมาจากฝั่งเราทั้งหมด และแก้ได้โดยไม่ต้องแตะการเชื่อมต่อเลย
# ก่อนแก้: หน่วงรวมประมาณ 61 วินาที
POLL = 10 # อ่านฐานข้อมูลทุก 10 วินาที
ON_CONFIRM = 30 # ต้องเห็นค่ากระแสต่อเนื่อง 30 วินาที ถึงจะฟันว่าเปิดเครื่อง
# หลังแก้: หน่วงรวมเหลือประมาณ 3 ถึง 4 วินาที
POLL = 1 # ฐานข้อมูลอยู่บนเครื่องเดียวกัน อ่านทุกวินาทีไม่กระทบใคร
ON_CONFIRM = 3 # กระแสเดินคือเครื่องเปิดแล้ว ไม่ต้องหน่วงยืนยัน
เหตุผลที่ลดค่ายืนยันจาก 30 เหลือ 3 ได้ คือการเปิดเครื่องเป็นเหตุการณ์ที่ต่อเนื่องอยู่แล้วโดยธรรมชาติ มันไม่ใช่สัญญาณกระพริบที่ต้องกรอง พอกระแสเริ่มเดินก็คือเปิดแล้วจริงๆ ส่วนการลดจังหวะอ่านจาก 10 เหลือ 1 วินาที ไม่กระทบใครเลยเพราะเป็นการอ่านฐานข้อมูลบนเครื่องเดียวกัน ไม่ได้ไปยิงบริการภายนอก หลักที่ผมสรุปไว้คือ เวลาจะลดความหน่วง ให้ลดค่ายืนยันกับจังหวะอ่านของเรา ไม่ใช่เพิ่มความถี่ในการรบกวนบริการปลายทาง
เช็คสุขภาพก่อนส่งทุกครั้ง แล้วบันทึกไว้เมื่อข้าม
ข้อสุดท้ายเป็นเรื่องกันข้อความหายเงียบ ถ้าตัวรับส่งข้อความตอบกลับมาเป็นแบบยิงแล้วจบ คือตอบว่าสำเร็จทันทีโดยยังไม่ได้ส่งจริง เราจะไม่มีทางรู้ว่าข้อความไปถึงหรือเปล่า วิธีที่ผมใช้คือเรียกจุดตรวจสุขภาพก่อนทุกครั้ง แล้วถ้าไม่ผ่านก็ข้ามพร้อมเขียนบันทึกไว้ ดีกว่ายิงทิ้งแล้วไม่รู้ตัว
<?php
// เรียก /health ก่อน /send ทุกครั้ง แล้วบันทึกเหตุผลถ้าข้าม
$ctx = stream_context_create(['http' => ['timeout' => 8, 'ignore_errors' => true]]);
$raw = @file_get_contents('http://127.0.0.1:8080/health', false, $ctx);
$ok = false; $alive = false; $err = '';
if ($raw !== false && ($h = json_decode($raw, true)) && is_array($h)) {
$ok = !empty($h['ok']);
$alive = !empty($h['session_alive']);
$err = $h['session_last_error'] ?? '';
}
if (!$ok || !$alive) {
$reason = ($raw === false) ? 'ติดต่อบริการไม่ได้'
: (!$alive ? "session ตาย ({$err})" : 'health ไม่ผ่าน');
file_put_contents($logFile,
sprintf("[%s] SKIP — %s\n", date('Y-m-d H:i:s'), $reason), FILE_APPEND);
exit(1);
}
// ผ่านแล้วค่อยยิงจริง
foreach ($recipients as $name => $target) { send_message($target, $text); }
- นับก่อน ดึงล็อกหนึ่งวันมาแยกตามชนิดเหตุการณ์ แล้วดูว่าชนิดไหนกินสัดส่วนเกินครึ่ง
- แยกชั้นผู้รับ ให้แต่ละคนได้เฉพาะสิ่งที่เขาต้องทำอะไรกับมันได้
- ตั้งเพดานจำนวนครั้ง ของทุกการเตือนซ้ำ และให้ข้อความสุดท้ายบอกว่าเป็นครั้งสุดท้าย
- ตั้งค่ารอให้รวมรอบที่ติดกัน โดยดูจากความยาวรอบจริงย้อนหลัง ไม่ใช่ตั้งจากความรู้สึก
- เก็บสถานะลงไฟล์ และเริ่มทำงานหลังรีสตาร์ตแบบเงียบเสมอ
- ลดความหน่วงที่ฝั่งเรา ห้ามเพิ่มความถี่ในการรบกวนบริการปลายทาง
- เช็คสุขภาพก่อนส่ง และบันทึกทุกครั้งที่ตัดสินใจข้าม
ส่วนตัวผมคิดว่าคุณภาพของระบบแจ้งเตือน วัดกันที่จำนวนข้อความที่คนรับยังยอมเปิดอ่าน ไม่ได้วัดที่ความแม่นยำของการตรวจจับ ระบบที่ตรวจจับถูกร้อยเปอร์เซ็นต์แต่ยิงวันละห้าสิบข้อความ มีค่าเท่ากับระบบที่ปิดอยู่ เพราะสุดท้ายคนก็ปิดการแจ้งเตือนทิ้งเหมือนกัน ใครจะเอาชุดค่าพวกนี้ไปปรับตามงานของตัวเองก็ตามสะดวกครับ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย