Explorar o código

Add chat template system with client-side substitution

Config-driven message templates (chat.templates in config.json) appear as a select picker in the chat panel. Selecting a template fills the reply textarea with variables substituted: guest name, wifi, door code, check-in/out dates (strftime format), reservation ID. Dates use a configurable default_datetime_format. wifi_ssid threaded through reservation spans and selection to make it available for substitution.
ZiB hai 4 meses
pai
achega
233042ceae
Modificáronse 3 ficheiros con 90 adicións e 9 borrados
  1. 15 0
      room_cal/_utils.py
  2. 7 0
      room_cal/api/_routes/chat.py
  3. 68 9
      room_cal/vue/calendar.html

+ 15 - 0
room_cal/_utils.py

@@ -398,6 +398,19 @@ class ReservationConfig:
     return cls(default=ReservationDefaultsConfig.from_dict(d.get('default', {})))
     return cls(default=ReservationDefaultsConfig.from_dict(d.get('default', {})))
 
 
 
 
+@dataclass
+class ChatConfig:
+  default_datetime_format: str = '%-d %B %Y'
+  templates: dict[str, str] = field(default_factory=dict)
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'ChatConfig':
+    return cls(
+      default_datetime_format=d.get('default_datetime_format', '%-d %B %Y'),
+      templates=d.get('templates', {}),
+    )
+
+
 @dataclass
 @dataclass
 class Config:
 class Config:
   global_: GlobalConfig = field(default_factory=GlobalConfig)
   global_: GlobalConfig = field(default_factory=GlobalConfig)
@@ -407,6 +420,7 @@ class Config:
   api_url: str | None = None
   api_url: str | None = None
   store: StoreConfig = field(default_factory=StoreConfig)
   store: StoreConfig = field(default_factory=StoreConfig)
   reservation: ReservationConfig = field(default_factory=ReservationConfig)
   reservation: ReservationConfig = field(default_factory=ReservationConfig)
+  chat: ChatConfig = field(default_factory=ChatConfig)
 
 
   @classmethod
   @classmethod
   def from_dict(cls, d: dict) -> 'Config':
   def from_dict(cls, d: dict) -> 'Config':
@@ -419,6 +433,7 @@ class Config:
       api_url=d.get('api_url'),
       api_url=d.get('api_url'),
       store=StoreConfig.from_dict(store_d),
       store=StoreConfig.from_dict(store_d),
       reservation=ReservationConfig.from_dict(d.get('reservation', {})),
       reservation=ReservationConfig.from_dict(d.get('reservation', {})),
+      chat=ChatConfig.from_dict(d.get('chat', {})),
     )
     )
 
 
 
 

+ 7 - 0
room_cal/api/_routes/chat.py

@@ -1,6 +1,7 @@
 from fastapi import APIRouter, HTTPException
 from fastapi import APIRouter, HTTPException
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
+from room_cal._utils import get_config
 from room_cal.service_api import ServiceAPI
 from room_cal.service_api import ServiceAPI
 from room_cal.service_api.types import ServiceAuthError
 from room_cal.service_api.types import ServiceAuthError
 
 
@@ -45,6 +46,12 @@ class _SendBody(BaseModel):
   body: str
   body: str
 
 
 
 
+@router.get('/chat/templates')
+async def get_chat_templates():
+  cfg = get_config().chat
+  return {'templates': cfg.templates, 'default_datetime_format': cfg.default_datetime_format}
+
+
 @router.get('/chat/{service_key}/ready')
 @router.get('/chat/{service_key}/ready')
 async def chat_send_ready(service_key: str):
 async def chat_send_ready(service_key: str):
   client = _get_chat_client(service_key)
   client = _get_chat_client(service_key)

+ 68 - 9
room_cal/vue/calendar.html

@@ -570,6 +570,11 @@
     .chat-status { padding: 14px 18px; font-size: 12.5px; color: var(--text-muted); }
     .chat-status { padding: 14px 18px; font-size: 12.5px; color: var(--text-muted); }
     .chat-error-txt { color: #c04a2a; }
     .chat-error-txt { color: #c04a2a; }
     .chat-reply { padding: 10px 18px 14px; }
     .chat-reply { padding: 10px 18px 14px; }
+    .chat-template-select {
+      width: 100%; margin-bottom: 7px; padding: 5px 8px; border-radius: 5px;
+      border: 1px solid var(--border); background: var(--bg); color: var(--text);
+      font-size: 12px; font-family: var(--sans); cursor: pointer;
+    }
     .chat-reply-input {
     .chat-reply-input {
       width: 100%;
       width: 100%;
       font-family: var(--sans);
       font-family: var(--sans);
@@ -1293,6 +1298,14 @@
               </div>
               </div>
             </div>
             </div>
             <div class="chat-reply">
             <div class="chat-reply">
+              <select
+                v-if="Object.keys(chatTemplates).length"
+                class="chat-template-select"
+                @change="e => { applyTemplate(e.target.value); e.target.value = ''; }"
+              >
+                <option value="">Insert template…</option>
+                <option v-for="(_, name) in chatTemplates" :key="name" :value="name">{{ name.replace(/_/g, ' ') }}</option>
+              </select>
               <textarea
               <textarea
                 v-model="chatReply"
                 v-model="chatReply"
                 class="chat-reply-input"
                 class="chat-reply-input"
@@ -1413,13 +1426,15 @@ createApp({
     const healthStatus  = ref('idle');
     const healthStatus  = ref('idle');
 
 
     // ── Chat ───────────────────────────────────────────────
     // ── Chat ───────────────────────────────────────────────
-    const chatThread     = ref(null);
-    const chatLoading    = ref(false);
-    const chatError      = ref(null);
-    const chatReply      = ref('');
-    const chatSending    = ref(false);
-    const chatSendError  = ref(null);
-    const chatSendReady  = ref(true);
+    const chatThread        = ref(null);
+    const chatLoading       = ref(false);
+    const chatError         = ref(null);
+    const chatReply         = ref('');
+    const chatSending       = ref(false);
+    const chatSendError     = ref(null);
+    const chatSendReady     = ref(true);
+    const chatTemplates     = ref({});
+    const chatTemplatesDtFmt = ref('%-d %B %Y');
     const syncEnabled   = ref(false);
     const syncEnabled   = ref(false);
     const bulkEnabled   = ref(false);
     const bulkEnabled   = ref(false);
     const bulkDays      = ref(new Set());
     const bulkDays      = ref(new Set());
@@ -1533,6 +1548,43 @@ createApp({
       }
       }
     }
     }
 
 
+    function _strftime(fmt, date) {
+      const pad = n => String(n).padStart(2, '0');
+      const MONTHS_LONG  = ['January','February','March','April','May','June','July','August','September','October','November','December'];
+      const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+      return fmt
+        .replace(/%-d/g, String(date.getDate()))
+        .replace(/%d/g,  pad(date.getDate()))
+        .replace(/%-m/g, String(date.getMonth() + 1))
+        .replace(/%m/g,  pad(date.getMonth() + 1))
+        .replace(/%B/g,  MONTHS_LONG[date.getMonth()])
+        .replace(/%b/g,  MONTHS_SHORT[date.getMonth()])
+        .replace(/%Y/g,  String(date.getFullYear()))
+        .replace(/%y/g,  String(date.getFullYear()).slice(-2));
+    }
+
+    function applyTemplate(name) {
+      const raw = chatTemplates.value[name];
+      if (!raw) return;
+      const sel   = selection.value;
+      const dtFmt = chatTemplatesDtFmt.value;
+      const checkin  = sel?.checkin_date  ? parseLocal(sel.checkin_date)  : null;
+      const checkout = sel?.checkout_date ? parseLocal(sel.checkout_date) : null;
+      const fmtDate_ = (d, fmt) => d ? _strftime(fmt || dtFmt, d) : '';
+      const resId = [serviceName(sel?.service), sel?.reservation_id].filter(Boolean).join(' ');
+      chatReply.value = raw.replace(/\{(\w+)(?::([^}]*))?\}/g, (_, key, fmt) => {
+        if (key === 'guest_name')     return sel?.guest_name  ?? '';
+        if (key === 'first_name')     return (sel?.guest_name ?? '').split(' ')[0] ?? '';
+        if (key === 'wifi_key')       return sel?.wifi_key    ?? '';
+        if (key === 'wifi_ssid')      return sel?.wifi_ssid   ?? '';
+        if (key === 'door_code')      return sel?.door_code   ?? '';
+        if (key === 'checkin')        return fmtDate_(checkin,  fmt);
+        if (key === 'checkout')       return fmtDate_(checkout, fmt);
+        if (key === 'reservation_id') return resId;
+        return '';
+      });
+    }
+
     function fmtChatTime(ts) {
     function fmtChatTime(ts) {
       if (!ts) return '';
       if (!ts) return '';
       const n = Number(ts);
       const n = Number(ts);
@@ -1938,6 +1990,7 @@ createApp({
         nights:         span.nights,
         nights:         span.nights,
         chat_id:        span.chat_id,
         chat_id:        span.chat_id,
         wifi_key:       span.wifi_key,
         wifi_key:       span.wifi_key,
+        wifi_ssid:      span.wifi_ssid,
         door_code:      span.door_code,
         door_code:      span.door_code,
       };
       };
     }
     }
@@ -1966,6 +2019,7 @@ createApp({
           nights,
           nights,
           chat_id:        res.chat_id ?? null,
           chat_id:        res.chat_id ?? null,
           wifi_key:       res.wifi_key ?? null,
           wifi_key:       res.wifi_key ?? null,
+          wifi_ssid:      res.wifi_ssid ?? null,
           door_code:      res.door_code ?? null,
           door_code:      res.door_code ?? null,
         });
         });
       }
       }
@@ -2439,6 +2493,11 @@ createApp({
 
 
     onMounted(() => {
     onMounted(() => {
       apiFetch(`${API_BASE}/services`).then(r => r?.json()).then(v => { if (v) services.value = v; }).catch(() => {});
       apiFetch(`${API_BASE}/services`).then(r => r?.json()).then(v => { if (v) services.value = v; }).catch(() => {});
+      apiFetch(`${API_BASE}/chat/templates`).then(r => r?.json()).then(v => {
+        if (!v) return;
+        if (v.templates) chatTemplates.value = v.templates;
+        if (v.default_datetime_format) chatTemplatesDtFmt.value = v.default_datetime_format;
+      }).catch(() => {});
       fetchMonth();
       fetchMonth();
     });
     });
 
 
@@ -2453,8 +2512,8 @@ createApp({
       bulkStatusSummary, bulkActiveDiscountType, bulkToggleClosed,
       bulkStatusSummary, bulkActiveDiscountType, bulkToggleClosed,
       CAT, DAYS,
       CAT, DAYS,
       chatThread, chatLoading, chatError, chatReply, chatSending, chatSendError, chatSendReady,
       chatThread, chatLoading, chatError, chatReply, chatSending, chatSendError, chatSendReady,
-      selectionSupportsChat, visibleChatMessages, chatSenderLabel, chatMessagesEl,
-      loadChat, sendChatMessage, fmtChatTime,
+      chatTemplates, selectionSupportsChat, visibleChatMessages, chatSenderLabel, chatMessagesEl,
+      loadChat, sendChatMessage, fmtChatTime, applyTemplate,
       runHealthCheck, toggleSync, toggleBulk, fetchDiff, fixDay, fixResChanges, fixBusy, fixDayBusy, fixMonthBusy, FIX,
       runHealthCheck, toggleSync, toggleBulk, fetchDiff, fixDay, fixResChanges, fixBusy, fixDayBusy, fixMonthBusy, FIX,
       monthCats, fixMonth, fixMonthFeeds, fixMonthScripts, reloadCaches,
       monthCats, fixMonth, fixMonthFeeds, fixMonthScripts, reloadCaches,
       toggleClosed, commitPrice, switchDiscountType, commitDiscount,
       toggleClosed, commitPrice, switchDiscountType, commitDiscount,