diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b924270 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# LINE Bot Configuration +# Required environment variables for NTPU LineBot + +# LINE Channel Access Token +# Get this from LINE Developer Console > Your Channel > Messaging API +LINE_CHANNEL_ACCESS_TOKEN=your_access_token_here + +# LINE Channel Secret +# Get this from LINE Developer Console > Your Channel > Basic settings +LINE_CHANNEL_SECRET=your_channel_secret_here diff --git a/README.md b/README.md index 557f80b..af3a8e1 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,289 @@ -# ntpu-linebot +# NTPU LineBot -一個可以查詢北大公開資訊的小工具\ -加 Line 好友後即可使用(聊天)\ -有發現 bug 或想加什麼功能都歡迎提出來討論 +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.13](https://img.shields.io/badge/python-3.13-blue.svg)](https://www.python.org/downloads/) +[![Docker](https://img.shields.io/badge/docker-ready-brightgreen.svg)](https://hub.docker.com/r/garyellow/ntpu-linebot) +[![Sanic](https://img.shields.io/badge/framework-sanic-blue.svg)](https://sanic.dev/) -ID:[@148wrcch](https://lin.ee/QiMmPBv) +A LINE Bot for querying National Taipei University (NTPU) public information. This bot provides convenient access to student information, course data, and contact details through an intuitive chat interface. -[![friend](/add_friend/S_add_friend_button.png)](https://lin.ee/QiMmPBv) +## ✨ Features -![qrcode](/add_friend/S_gainfriends_qr.png) +### 📚 Course Information +- **Course Search**: Find courses by course name (Day & Night divisions) +- **Teacher Search**: Find courses taught by specific teachers (Day & Night divisions) +- **Course Details**: View detailed course information including syllabus, schedule, and location -## 目前功能清單 +### 📞 Contact Information +- **Directory Search**: Find contact information for departments and staff +- **Organization Search**: Browse administrative and academic units +- **Emergency Contacts**: Quick access to campus emergency phone numbers +- **Individual Contacts**: Find staff members with their extensions and email addresses -1. 輸入**學號**查**姓名** (日夜) -2. 輸入**姓名**查**學號** (日) -3. 輸入**系名**查**系代碼** (日) -4. 輸入**系代碼**查**系名** (日) -5. 輸入**系級**查**學生名單** (日) -6. 輸入**課程名稱**查**課程清單** (日夜) -7. 輸入**教師姓名**查**授課課程清單** (日夜) -8. 輸入**單位/成員名稱**查**聯繫方式** (日夜) +### 🎓 Student Information (Legacy) +- **Student ID → Name**: Query student name by ID (Day & Night divisions) +- **Name → Student ID**: Query student ID by name (Day division only) +- **Department Name → Code**: Get department code by name (Day division only) +- **Department Code → Name**: Get department name by code (Day division only) +- **Student Lists**: Get student list by admission year and department (Day division only) -## 資料來源 +## 📞 Add as Friend -1. [國立臺北大學數位學苑 2.0](https://lms.ntpu.edu.tw) -2. [國立臺北大學校園聯絡簿](https://sea.cc.ntpu.edu.tw/pls/ld/campus_dir_m.main) -3. [國立臺北大學課程查詢系統](https://sea.cc.ntpu.edu.tw/pls/dev_stud/course_query_all.CHI_MAIN) +**LINE ID**: [@148wrcch](https://lin.ee/QiMmPBv) -## 開發 +[![Add Friend](add_friend/S_add_friend_button.png)](https://lin.ee/QiMmPBv) -本專案使用 [Poetry](https://python-poetry.org/) 作為套件管理及建立虛擬環境的工具\ -詳細安裝及使用方式請參考官方文件,以下為常用的指令 +![QR Code](add_friend/S_gainfriends_qr.png) -### 安裝套件 +## 🏗️ Architecture + +This project is built with modern async Python technologies: + +- **Framework**: Sanic (Async web framework) +- **LINE SDK**: line-bot-sdk v3 (LINE Bot API support) +- **Web Scraping**: BeautifulSoup4 + httpx (Async HTTP client) +- **Caching**: asyncache + cachetools (TTL and LRU caching) +- **Containerization**: Docker with multi-stage builds +- **Error Handling**: Comprehensive exception handling and service monitoring + +### Core Components +- **Message Router**: Intelligent message routing between bot modules +- **Bot Modules**: Specialized handlers for ID, Course, and Contact queries +- **Health Monitoring**: Service health checks and automatic recovery +- **Rich Menu Support**: Enhanced user interaction capabilities + +## 📊 Data Sources + +1. [NTPU Campus Directory](https://sea.cc.ntpu.edu.tw/pls/ld/campus_dir_m.main) - Contact information and organizational structure +2. [NTPU Course Query System](https://sea.cc.ntpu.edu.tw/pls/dev_stud/course_query_all.CHI_MAIN) - Course schedules and information +3. [NTPU Digital Learning Platform 2.0](https://lms.ntpu.edu.tw) *(Legacy student data only)* + +> **Important Note**: Student-related functions contain data only up to Academic Year 113 (2024). Course and contact information are updated dynamically. + +## 🚀 Quick Start + +### Prerequisites + +- Python 3.13+ +- [Poetry](https://python-poetry.org/) (for development) +- Docker (for production deployment) + +### Development Setup + +1. **Clone the repository** + ```bash + git clone https://github.com/garyellow/ntpu-linebot.git + cd ntpu-linebot + ``` + +2. **Install dependencies** + ```bash + poetry install + ``` + +3. **Activate virtual environment** + ```bash + poetry shell + ``` + +4. **Set environment variables** + ```bash + # Option 1: Copy and edit .env file + cp .env.example .env + # Edit .env with your LINE channel credentials + + # Option 2: Export directly (Windows CMD) + set LINE_CHANNEL_ACCESS_TOKEN=your_access_token + set LINE_CHANNEL_SECRET=your_channel_secret + + # Option 2: Export directly (PowerShell) + $env:LINE_CHANNEL_ACCESS_TOKEN="your_access_token" + $env:LINE_CHANNEL_SECRET="your_channel_secret" + + # Option 2: Export directly (Unix/Linux/macOS) + export LINE_CHANNEL_ACCESS_TOKEN="your_access_token" + export LINE_CHANNEL_SECRET="your_channel_secret" + ``` + +5. **Run development server** + ```bash + sanic app:app --debug + ``` + +The server will start on `http://localhost:8000` + +## 🐳 Production Deployment + +### Using Docker Compose + +1. **Prepare environment file** + ```bash + cd docker + cp .env.example .env + # Edit .env with your LINE channel credentials + ``` + +2. **Deploy** + ```bash + docker compose up -d + ``` + +The service will be available on port `10000`. + +### Manual Docker Run ```bash -poetry install +docker run -d \ + -p 10000:10000 \ + -e LINE_CHANNEL_ACCESS_TOKEN="your_token" \ + -e LINE_CHANNEL_SECRET="your_secret" \ + garyellow/ntpu-linebot:latest ``` -### 進入虛擬環境 +### Update to Latest Version ```bash -poetry shell +cd docker +docker compose down +docker compose pull +docker compose up -d ``` -### 測試執行 - +Or use the provided update script: ```bash -sanic app:app --debug +chmod +x update.sh +./update.sh ``` -### 生產環境執行(docker) +## 📖 Usage Examples -> 需要先複製一份 docker/.env.example 到 docker/.env 並設定相關參數 +### Student Queries +``` +學生 412345678 # Query by student ID +學生 小明 # Query by name (partial match) +學生 林小明 # Query by full name +``` -```bash -cd docker -docker compose up -d +### Department Queries +``` +科系 資工系 # Get department code by short name +科系 資訊工程學系 # Get department code by full name +系代碼 85 # Get department name by code +所有系代碼 # Display all department codes ``` -> 預設 port 為 10000 +### Year-based Student Lists +``` +學年 112 # Select department for year 112 students +學年 113 # Select department for year 113 students +``` -## 生產環境更新(latest) +### Course Queries +``` +課程 程式設計 # Find programming courses +教師 李小美 # Find courses by teacher name +``` -```bash -docker compose down -docker compose pull -# docker image prune -f # 有需要可以清除舊的 image -docker compose up -d +### Contact Queries +``` +聯繫 資工系 # Find contact info for CS department +聯繫 註冊組 # Find contact info for registration office +緊急 # Display emergency contact numbers +``` + +### Help and Instructions +``` +使用說明 # Show detailed usage instructions +help # Show usage instructions (English) ``` -> 也可以直接執行 `update.sh` 來更新 +### Interactive Features +- **Rich Menus**: Use buttons for easy navigation +- **Postback Actions**: Click on course names for detailed information +- **Carousel Templates**: Browse multiple results easily +- **Copy Actions**: One-click copy for phone numbers and emails + +## 🔧 Configuration + +### Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `LINE_CHANNEL_ACCESS_TOKEN` | LINE Bot channel access token | Yes | +| `LINE_CHANNEL_SECRET` | LINE Bot channel secret | Yes | + +### Health Checks + +- `GET /healthz` - Basic health check endpoint +- `GET /healthy` - Comprehensive service health check (verifies all modules) + +### API Endpoints + +- `GET /` - Redirects to project GitHub repository +- `POST /callback` - LINE Bot webhook endpoint for message processing + +## 📁 Project Structure + +``` +ntpu-linebot/ +├── app.py # Main Sanic application with webhook handling +├── pyproject.toml # Project dependencies and build configuration +├── poetry.lock # Locked dependency versions +├── Dockerfile # Multi-stage Docker build configuration +├── ntpu_linebot/ # Core bot modules +│ ├── __init__.py # Package initialization and exports +│ ├── abs_bot.py # Abstract bot base class +│ ├── line_api_util.py # LINE API client wrapper +│ ├── line_bot_util.py # LINE Bot message utilities +│ ├── route_util.py # Message routing and event handling +│ ├── normal_util.py # Common utility functions +│ ├── sticker_util.py # Sticker message handling +│ ├── contact/ # Contact search module +│ │ ├── __init__.py # Module exports +│ │ ├── bot.py # Contact bot implementation +│ │ ├── contact.py # Contact data models +│ │ ├── request.py # Web scraping for contact data +│ │ └── util.py # Contact utility functions +│ ├── course/ # Course search module +│ │ ├── __init__.py # Module exports +│ │ ├── bot.py # Course bot implementation +│ │ ├── course.py # Course data models +│ │ ├── request.py # Web scraping for course data +│ │ └── util.py # Course utility functions +│ └── id/ # Student ID module +│ ├── __init__.py # Module exports +│ ├── bot.py # ID bot implementation +│ ├── request.py # Student data handling +│ └── util.py # ID utility functions +├── docker/ # Docker deployment configurations +│ ├── docker-compose.yml # Production deployment setup +│ ├── update.sh # Automated update script +│ └── .env.example # Environment variables template +├── rich_menu/ # LINE Rich Menu assets +│ └── default/ # Default rich menu configuration +│ ├── default.png # Menu background image +│ └── default_richmenu.json # Menu structure definition +├── add_friend/ # Friend invitation assets +│ ├── *_add_friend_button.png # Add friend buttons +│ └── *_gainfriends_qr.png # QR codes for different sizes +└── assets/ # Static application assets + └── rip.png # Application assets +``` + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit issues and feature requests. + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## ⚠️ Disclaimer + +This bot aggregates publicly available information from NTPU systems. The data may not always be accurate or up-to-date. Use at your own discretion. + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. diff --git a/docker/update.sh b/docker/update.sh index fe7a15d..3c7d624 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -1,4 +1,4 @@ docker compose down docker compose pull -docker image prune -f +# docker image prune -f docker compose up -d diff --git a/ntpu_linebot/course/bot.py b/ntpu_linebot/course/bot.py index ae7674e..616ef29 100644 --- a/ntpu_linebot/course/bot.py +++ b/ntpu_linebot/course/bot.py @@ -1,9 +1,10 @@ # -*- coding:utf-8 -*- from random import sample from re import IGNORECASE, fullmatch, match, search -from typing import Optional +from typing import Optional, cast from linebot.v3.messaging.models import ( + Action, ButtonsTemplate, CarouselColumn, CarouselTemplate, @@ -73,6 +74,9 @@ async def handle_text_message( """處理文字訊息""" if match(self.__SEARCH_REGEX, payload, IGNORECASE): + kind = SearchKind.NONE + criteria = "" + if m := search(self.__CLASS_REGEX, payload, IGNORECASE): criteria = m.group() kind = SearchKind.TITLE @@ -87,6 +91,7 @@ async def handle_text_message( altText="請選擇要查詢的課程", template=self.__choose_course_message(courses), sender=get_sender(self.__SENDER_NAME), + quickReply=None, ) ] @@ -103,6 +108,7 @@ async def handle_text_message( text=f"查無{condition_str}含有「{criteria}」的課程,請重新輸入", sender=get_sender(self.__SENDER_NAME), quoteToken=quote_token, + quickReply=None, ) ] @@ -113,7 +119,6 @@ async def handle_postback_event(self, payload: str) -> list[Message]: if payload.startswith("授課課程"): payload = payload.split(self.split_char)[1] - if courses := search_simple_courses_by_criteria_and_kind( payload, SearchKind.STRICT_TEACHER, @@ -123,6 +128,7 @@ async def handle_postback_event(self, payload: str) -> list[Message]: altText="請選擇要查詢的課程", template=self.__choose_course_message(courses), sender=get_sender(self.__SENDER_NAME), + quickReply=None, ) ] @@ -130,6 +136,8 @@ async def handle_postback_event(self, payload: str) -> list[Message]: TextMessage( text=f"查無授課教師為「{payload}」的課程", sender=get_sender(self.__SENDER_NAME), + quickReply=None, + quoteToken=None, ) ] @@ -140,6 +148,7 @@ async def handle_postback_event(self, payload: str) -> list[Message]: altText=f"{course.title}的課程資訊", template=self.__course_info_message(course), sender=get_sender(self.__SENDER_NAME), + quickReply=None, ) ] @@ -147,6 +156,8 @@ async def handle_postback_event(self, payload: str) -> list[Message]: TextMessage( text=f"查無 uid 為「{payload}」的課程", sender=get_sender(self.__SENDER_NAME), + quickReply=None, + quoteToken=None, ) ] @@ -162,17 +173,25 @@ def __course_info_message(self, course: Course) -> ButtonsTemplate: Returns: ButtonsTemplate: The message template containing course information and actions. """ - - teacher_actions = [ - URIAction(label=f"教師課表({name})", uri=url) - for (name, url) in course.teachers_name_url - ] + teacher_actions: list[Action] = cast( + list[Action], + [ + URIAction(label=f"教師課表({name})"[:20], uri=url, altUri=None) + for (name, url) in course.teachers_name_url + ], + ) if len(teacher_actions) == 1: teacher_actions.append( - PostbackAction( - label="查看教師資訊", - data=f"查看資訊{self.split_char}{course.teachers[0]}", + cast( + Action, + PostbackAction( + label="查看教師資訊", + data=f"查看資訊{self.split_char}{course.teachers[0]}", + displayText=None, + inputOption=None, + fillInText=None, + ), ) ) @@ -180,8 +199,8 @@ def __course_info_message(self, course: Course) -> ButtonsTemplate: teacher_actions = sample(teacher_actions, 2) actions = [ - URIAction(label="課程大綱", uri=course.detail_url), - URIAction(label="課程查詢系統", uri=course.course_query_url), + URIAction(label="課程大綱", uri=course.detail_url, altUri=None), + URIAction(label="課程查詢系統", uri=course.course_query_url, altUri=None), *teacher_actions, ] @@ -197,7 +216,16 @@ def __course_info_message(self, course: Course) -> ButtonsTemplate: if len(text := "\n".join(texts)) > 60: text = text[:59] + "…" - return ButtonsTemplate(title=course.title, text=text, actions=actions) + return ButtonsTemplate( + title=course.title, + text=text, + actions=actions, + thumbnailImageUrl=None, + imageAspectRatio=None, + imageBackgroundColor=None, + imageSize=None, + defaultAction=None, + ) def __generate_course_text(self, course: SimpleCourse) -> str: """ @@ -237,9 +265,11 @@ def __choose_course_message(self, courses: list[SimpleCourse]) -> CarouselTempla texts = [self.__generate_course_text(course) for course in courses] actions = [ PostbackAction( - label=course.title, + label=(course.title if len(course.title) <= 20 else course.title[:19] + '…'), displayText=f"查詢 {course.title} 的課程資訊", data=course.uid, + inputOption=None, + fillInText=None, ) for course in courses ] @@ -253,9 +283,14 @@ def __choose_course_message(self, courses: list[SimpleCourse]) -> CarouselTempla text="選擇要查詢的課程:\n\n" + "\n\n".join(texts[i * 3 : (i + 1) * 3]), actions=actions[i * 3 : (i + 1) * 3], + thumbnailImageUrl=None, + imageBackgroundColor=None, + defaultAction=None, ) for i in range(len(actions) // 3) - ] + ], + imageAspectRatio=None, + imageSize=None, ) diff --git a/ntpu_linebot/course/util.py b/ntpu_linebot/course/util.py index 585b813..98d0336 100644 --- a/ntpu_linebot/course/util.py +++ b/ntpu_linebot/course/util.py @@ -61,6 +61,7 @@ async def search_course_by_uid(uid: str) -> Course: class SearchKind(Enum): """Enumeration representing the search arguments.""" + NONE = auto() NO = auto() TITLE = auto() TEACHER = auto() diff --git a/ntpu_linebot/line_bot_util.py b/ntpu_linebot/line_bot_util.py index f540502..0025791 100644 --- a/ntpu_linebot/line_bot_util.py +++ b/ntpu_linebot/line_bot_util.py @@ -8,7 +8,9 @@ from .sticker_util import STICKER -EMPTY_POSTBACK_ACTION = PostbackAction(label=" ", data="null") +EMPTY_POSTBACK_ACTION = PostbackAction( + label=" ", data="null", displayText=None, inputOption=None, fillInText=None +) def get_sender(name: Optional[str] = None) -> Sender: @@ -109,6 +111,8 @@ def instruction() -> list[TextMessage]: ] ), sender=mes_sender, + quickReply=None, + quoteToken=None, ), TextMessage( text="\n\n".join( @@ -121,13 +125,19 @@ def instruction() -> list[TextMessage]: ] ), sender=mes_sender, + quickReply=None, + quoteToken=None, ), TextMessage( text="部分內容是由相關資料推斷\n不一定為正確資訊", sender=mes_sender, + quickReply=None, + quoteToken=None, ), TextMessage( text="資料來源:國立臺北大學\n數位學苑2.0(已無新資料)\n校園聯絡簿\n課程查詢系統", sender=mes_sender, + quickReply=None, + quoteToken=None, ), ]