[教程] 用 Waveshare SIM7600G-H 4G Dongle + gammu-smsd + Telegram Bot 做一个专用短信网关
用4G Dongle+Linux+Telegram Bot搭建专用短信网关教程
关键信息与硬件方案
楼主 uhhhh 分享了一套基于 Linux (Ubuntu Server) 和 Waveshare SIM7600G-H 4G Dongle 的短信网关方案,旨在解决国内手机号长期插电收验证码导致电池鼓包的问题。核心架构为:Dongle + gammu-smsd (后端服务) + Python Telegram Bot (前端交互)。
- 硬件:Waveshare SIM7600G-H 4G Dongle(需刷 C1S 固件以获取返现邮件)#p-7026874-h-1。推荐模块包括移远 EC20(约50元,适合美国频段)或 EG25-G(支持 VoLTE,约70元)#p-7026874-h-8, #p-7026874-h-15。
- 软件环境:Ubuntu Server 24.04 LTS,安装 gammu, gammu-smsd, python3 及 python-telegram-bot[job-queue] #p-7026874-h-2, #p-7026874-h-61。
- 核心脚本:提供完整的 Python 脚本 (bot.py),实现短信自动转发、按关键词路由到不同 Telegram Chat/Topic、支持在 TG 内回复短信、以及 /send 命令交互式发短信 #p-7026874-h-6, #p-7026874-h-62。
- 配置逻辑:使用 files backend 将短信存入 /var/spool/gammu/inbox/,Python 脚本轮询该目录,解析文件名或文件头获取发送者号码和时间,合并长短信后通过 Telegram Bot API 转发 #p-7026874-h-5, #p-7026874-h-6。
经验与数据点
- 电池寿命痛点:楼主连续干胀坏两台手机电池,即使使用“20%-80%”充电限制脚本或直通供电,旧手机在长期插电环境下通常撑不过半年至一年 #p-7026874-h-1, #p-7026874-h-6, #p-7026874-h-9。
- 硬件选型建议:
- 若需打电话(VoLTE),推荐移远 EG25-G,通过美国三大运营商认证 #p-7026874-h-15。
- 若仅收短信,Waveshare SIM7600G-H 频段最全且便宜;或闲鱼淘移远 EC20 #p-7026874-h-1, #p-7026874-h-8。
- 树莓派供电可能不足,建议搭配外接供电 USB Hub 或使用 Dell/HP 小主机(功耗可控,性能更好)#p-7026874-h-13, #p-7026874-h-14。
- 替代方案对比:
- 刷 OpenWrt 的安卓设备:成本极低(十几至二十元),但需较强动手能力,且部分方案仅支持转发不支持发送短信(如腾讯要求回复特定内容时失效)#p-7026874-h-39, #p-7026874-h-40。
- 碎屏儿童手表/旧手机:虽有人尝试,但电池问题依旧存在,且安卓系统兼容性复杂 #p-7026874-h-51, #p-7026874-h-28。
- 商业服务:淘宝上有猫池或短信转发设备,但需走第三方服务器,存在隐私泄露及服务跑路风险 #p-7026874-h-34。
争议与不同意见
- 长期插电对电池的影响:用户
nifury认为现代手机充满后由充电线直接供电,电池不循环充放电,20/80限制反而可能有害;但楼主实测旧手机即使如此操作也迅速鼓包 #p-7026874-h-7, #p-7026874-h-9。其他用户反馈 Pixel 或 iPhone X 长期插电数年无碍,存在个体差异 #p-7026874-h-12, #p-7026874-h-13, #p-7026874-h-27。 - 美国频段兼容性:用户
尼古丁真询问国内设备是否适用于美国,楼主指出 SIM7600G-H 虽频段全,但其他廉价模块可能不支持美国主要频段,导致信号差 #p-7026874-h-22, #p-7026874-h-53。 - 计费问题:若设备放置在美国,短信费用按美国漫游或本地资费计算;若放置在国内则按国内资费 #p-7026874-h-44, #p-7026874-h-45。
风险/限制/注意事项
- 权限配置:需将执行脚本的用户加入
gammu组,否则无法读写/var/spool/gammu/inbox/目录 #p-7026874-h-5。 - 路径稳定性:避免使用
/dev/ttyUSBx这种动态变化的路径,推荐使用/dev/serial/by-id/usb-SIMCOM_...固定路径 #p-7026874-h-3。 - Telegram Bot 设置:需获取 Chat ID(群聊为负数,私聊为正数)和 User ID;若使用 Forum Topics,脚本会自动为每个号码创建 Topic 并映射 #p-7026874-h-2, #p-7026874-h-7。
- 时区处理:脚本支持区分 Network Timezone(短信中心时区)和 Local Timezone,并在转发消息中同时显示两种时间,避免混淆 #p-7026874-h-7。
本帖子AI生成+微调 懒得自己写这么长的教程 很多人都有「国内手机号还要长期收验证码」的需求,但直接拿一台旧手机常年插电非常伤电池——我自己已经连续干胀坏了两台手机的电池 所以我最后换成了 4G dongle + Linux 主机 + gammu-smsd + Telegram bot ,实现「短信自动转发到 TG、在 TG 里直接回短信」。 这个帖子分享一下Linux环境下 (我用miniPC跑Ubuntu Server) 的配置思路和脚本,供大家参考。 #p-7026874-h-1-waveshare-sim7600g-h-4g-dongle-11. 硬件:Waveshare SIM7600G-H 4G Dongle 我用的是这款: Waveshare SIM7600G-H 4G DONGLE Module an Internet Access Module for Raspberry Pi GNSS Global Communication 购买链接(AliExpress): https://www.aliexpress.us/item/3256807290338687.html 记得刷C1S, 访问一下隔天应该能收到targeted邮件20%返现 #p-7026874-h-2-22. 软件环境假设 下面以 Ubuntu Server 为例(我自己的环境是 Ubuntu 24.04 LTS),其他 Debian 系基本类似: sudo apt update sudo apt install gammu gammu-smsd python3 python3-venv 你还需要一个 Telegram bot(用 BotFather 建一个就行)和一个能看到数字 ID 的客户端(比如 telegram-cli / bot 管理机器人 等,用来拿 chat_id 和自己的 user_id )。 #p-7026874-h-3-dongle-devserialby-id-33. 识别 dongle:建议用 /dev/serial/by-id 插上 dongle 后,看一下系统识别情况: dmesg | grep ttyUSB ls /dev/ttyUSB* 通常能看到一串 /dev/ttyUSB0 /dev/ttyUSB1 … 但 重启之后数字可能会变 ,所以不建议在配置里写死 /dev/ttyUSB3 这类路径。 推荐用: ls -l /dev/serial/by-id/ 会看到类似: lrwxrwxrwx 1 root root 13 Nov 17 12:00 usb-SIMCOM_SIM7600G-H_123456789ABC-if03-port0 -> ../../ttyUSB3 记下这个 usb-SIMCOM_SIM7600G-H_...-if03-port0 ,后面 gammu 用这个路径就不会因为重启而变。 示例路径: /dev/serial/by-id/usb-SIMCOM_SIM7600G-H_123456789ABC-if03-port0 #p-7026874-h-4-at-44. 简单 AT 测试(可选) 你可以用 minicom 快速确认 dongle 正常工作: sudo apt install minicom sudo minicom -D /dev/serial/by-id/usb-SIMCOM_SIM7600G-H_123456789ABC-if03-port0 -b 115200 在 minicom 里输入: AT → 得到 OK AT+CSQ → 查看信号强度 AT+CREG? → 注册状态 AT+CMGL="ALL" → 看已有短信(如果模块默认是 text mode) 确认没问题后, Ctrl+A 然后 X 退出 minicom。 #p-7026874-h-5-gammu-smsd-55. 配置 gammu-smsd(只给大方向) gammu-smsd 官方文档在这里: https://docs.gammu.org/smsd/smsd.html 我的核心思路是: 使用 files backend :所有收到的短信写入 /var/spool/gammu/inbox/IN*.txt ; 我自己的 Python 脚本去扫描这个目录、做转发和发送。 一个非常简化的 /etc/gammu-smsdrc 示例(仅供参考,具体以 docs 为准): [gammu] port = /dev/serial/by-id/usb-SIMCOM_SIM7600G-H_123456789ABC-if03-port0 connection = at [smsd] Service = files InboxPath = /var/spool/gammu/inbox/ OutboxPath = /var/spool/gammu/outbox/ SentSMSPath = /var/spool/gammu/sent/ ErrorSMSPath = /var/spool/gammu/error/ LogFile = /var/log/gammu-smsd.log PidFile = /var/run/gammu-smsd.pid 需要注意的是/var/spool这个文件夹权限是这样的: rwxrwx--- 2 gammu gammu 4 KiB Sat Mar 30 21:01:00 2024 error/ rwxrwx--- 2 gammu gammu 4 KiB Mon Nov 17 13:37:02 2025 inbox/ rwxrwx--- 2 gammu gammu 4 KiB Mon Nov 17 13:36:49 2025 outbox/ rwxrwx--- 2 gammu gammu 4 KiB Mon Nov 17 13:36:49 2025 sent/ 你会需要把自己加入gammu组 (至少你的python脚本执行的用户要有权限): sudo usermod -aG gammu "$USER" 然后启动: sudo systemctl enable --now gammu-smsd.service 可以简单测试一下手动发短信(看命令有没有报错即可): echo "test" | sudo gammu-smsd-inject TEXT +86xxxxxxxxxxx -text - #p-7026874-h-6-telegram-66. Telegram 网关脚本 我写了一个 Python 脚本来做这些事情: 定时扫描 /var/spool/gammu/inbox ; 从文件名解析 号码、时间 ,文本就是文件的内容; 按号码 & 时间聚合长短信(同一号码、中心时间相差 ≤5 秒的拼成一条); 按关键词规则转发到不同的 Telegram chat; 如果 chat 开启了 forum topics,则为每个号码单独创建一个 topic,并把该号码的短信都发到对应 topic 里; 在 topic 里回复 = 回短信; 支持 /send 命令交互式发短信; 使用 processed_files.json 记住已处理的短信文件,防止重启后把所有历史短信重发一遍; 同时显示 网络时间(短信中心时区) 和 本地时间 。 #p-7026874-h-61-76.1 依赖安装 依赖就一个python telegram bot pip install "python-telegram-bot[job-queue]" #p-7026874-h-62-botpy-86.2 bot.py 脚本 把下面这段保存为 bot.py bot.py #!/usr/bin/env python3 import asyncio import json import os import time import subprocess from pathlib import Path from typing import Dict, Any, List, Optional, Tuple from datetime import datetime, timedelta from zoneinfo import ZoneInfo from telegram import ( Update, InlineKeyboardMarkup, InlineKeyboardButton, Chat, ) from telegram.ext import ( Application, CommandHandler, MessageHandler, CallbackQueryHandler, ContextTypes, filters, ) CONFIG_PATH = "config.json" # ----------------------- # Config & persistence # ----------------------- def load_processed_files(path: str) -> set[str]: p = Path(path) if not p.exists(): return set() try: with p.open("r", encoding="utf-8") as f: data = json.load(f) # Expecting a list of file paths if isinstance(data, list): return set(str(x) for x in data) except Exception as e: print(f"Failed to load processed files from {path}: {e}") return set() def save_processed_files(path: str, processed: set[str]) -> None: p = Path(path) try: with p.open("w", encoding="utf-8") as f: json.dump(sorted(list(processed)), f, ensure_ascii=False, indent=2) except Exception as e: print(f"Failed to save processed files to {path}: {e}") def load_config(path: str) -> Dict[str, Any]: with open(path, "r", encoding="utf-8") as f: return json.load(f) def load_topic_mapping(path: str) -> Dict[str, Any]: p = Path(path) if not p.exists(): return {"chats": {}} with p.open("r", encoding="utf-8") as f: return json.load(f) def save_topic_mapping(path: str, data: Dict[str, Any]) -> None: p = Path(path) with p.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) # ----------------------- # SMS file parsing (Gammu files backend) # ----------------------- def parse_gammu_inbox_file(path: Path, default_cc: str) -> Optional[Dict[str, Any]]: """ Parse a Gammu files-backend inbox .txt file. There are (at least) two formats we want to support: 1) "Header + Text" format (classic Gammu): From: +123456789 Sent: 2025-11-18 00:47:27 Received: ... Class: -1 Text: Hello World 2) "Raw text only" format, where the sender and timestamp are encoded in the filename, e.g.: IN20251118_004727_02_10010_05.txt ^ ^ ^ ^ ^ | | | | | | | | | +-- part index | | | +-------------------- sender (phone / shortcode) | | +---------------------------- some index | +------------------------------------ time (HHMMSS) +-------------------------------------- "IN" + date (YYYYMMDD) This function will: - Prefer explicit "From:" / "Sent:" headers if present. - Otherwise, fall back to decoding phone & time from the filename. - Always normalize the phone using default_cc when necessary. """ from_number: Optional[str] = None sent_time: Optional[str] = None text_lines: List[str] = [] # 1) Try to parse headers, if they exist try: with path.open("r", encoding="utf-8", errors="replace") as f: text_lines = [line.rstrip("\n") for line in f] except Exception as e: print(f"Failed to read file {path}: {e}") return None # 2) If no sender/time in headers, try to decode from filename if from_number is None or not from_number.strip(): stem = path.stem # e.g. "IN20251118_004727_02_10010_05" parts = stem.split("_") # Expect: ["INYYYYMMDD", "HHMMSS", "<index>", "<sender>", "<part>"] if len(parts) >= 5 and parts[0].startswith("IN"): date_raw = parts[0][2:] # strip "IN" time_raw = parts[1] raw_sender = parts[3] # Normalize phone using default country code from_number = normalize_phone(raw_sender, default_cc) # Decode timestamp if possible if len(date_raw) == 8 and len(time_raw) == 6: sent_time = ( f"{date_raw[0:4]}-{date_raw[4:6]}-{date_raw[6:8]} " f"{time_raw[0:2]}:{time_raw[2:4]}:{time_raw[4:6]}" ) else: print(f"Warning: cannot decode sender from filename: {path}") if from_number is None: print(f"Warning: no From/Number field and no decodable sender in {path}") return None text = "\n".join(text_lines).strip() return { "from": from_number, "sent": sent_time, "text": text, "file": str(path), } # ----------------------- # Gammu sending # ----------------------- def normalize_phone(raw: str, default_cc: str) -> str: raw = raw.strip() if raw.startswith("+") or raw.startswith("00"): return raw # Simple approach: prepend default country code return default_cc + raw def send_sms_via_gammu( gammu_cmd: str, number: str, text: str, ) -> Tuple[bool, str]: """ Use a gammu command to send SMS. Recommended with gammu-smsd: gammu-smsd-inject TEXT <number> -text "..." Return (success, output_text) """ cmd = [gammu_cmd, "TEXT", number, "-text", text] try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=60, ) except Exception as e: return False, f"Failed to run {gammu_cmd}: {e}" success = (result.returncode == 0) output = (result.stdout or "") + (result.stderr or "") return success, output # ----------------------- # In-memory state # ----------------------- class BotState: def __init__(self, config: Dict[str, Any]): self.config = config # Load processed files from persistent storage self.processed_files_file: str = config.get("processed_files_file", "processed_files.json") self.processed_files: set[str] = load_processed_files(self.processed_files_file) # sender -> {"texts": [str], "sent_times": [str], "first_ts": float, "last_ts": float} self.pending_incoming: Dict[str, Dict[str, Any]] = {} # chat info cache self.chat_info_cache: Dict[str, Dict[str, Any]] = {} # topic mapping self.topic_mapping = load_topic_mapping(config["topic_mapping_file"]) # user_id -> pending send state self.pending_send: Dict[int, Dict[str, Any]] = {} # ----------------------- # Topic mapping helpers # ----------------------- def get_chat_entry(state: BotState, chat_id: int) -> Dict[str, Any]: cid = str(chat_id) chats = state.topic_mapping.setdefault("chats", {}) return chats.setdefault(cid, { "phone_to_topic": {}, "topic_to_phone": {}, }) def get_topic_for_phone( state: BotState, chat_id: int, phone: str, ) -> Optional[int]: entry = get_chat_entry(state, chat_id) tid = entry["phone_to_topic"].get(phone) if tid is None: return None return int(tid) def set_topic_for_phone( state: BotState, chat_id: int, phone: str, topic_id: int, ) -> None: entry = get_chat_entry(state, chat_id) entry["phone_to_topic"][phone] = str(topic_id) entry["topic_to_phone"][str(topic_id)] = phone def get_phone_for_topic( state: BotState, chat_id: int, topic_id: int, ) -> Optional[str]: entry = get_chat_entry(state, chat_id) return entry["topic_to_phone"].get(str(topic_id)) # ----------------------- # Forwarding rules # ----------------------- def find_matching_rules( config: Dict[str, Any], text: str, ) -> List[Dict[str, Any]]: text_lower = text.lower() rules = config.get("forwarding_rules", []) matches: List[Dict[str, Any]] = [] for rule in rules: keyword = rule.get("keyword", "") kw = str(keyword) if kw == "*": matches.append(rule) else: if kw.lower() in text_lower: matches.append(rule) return matches # ----------------------- # Telegram handlers # ----------------------- def is_allowed_user(config: Dict[str, Any], user_id: int) -> bool: return user_id in config.get("allowed_user_ids", []) async def ensure_chat_info_cached( bot_state: BotState, context: ContextTypes.DEFAULT_TYPE, chat_id: int, ) -> Dict[str, Any]: cid = str(chat_id) if cid not in bot_state.chat_info_cache: chat: Chat = await context.bot.get_chat(chat_id) bot_state.chat_info_cache[cid] = { "is_forum": bool(getattr(chat, "is_forum", False)), } return bot_state.chat_info_cache[cid] async def get_or_create_topic_id( bot_state: BotState, context: ContextTypes.DEFAULT_TYPE, chat_id: int, phone: str, ) -> Optional[int]: """ If the chat supports forum topics, get or create a topic for this phone number. If not supported, returns None. """ chat_info = await ensure_chat_info_cached(bot_state, context, chat_id) if not chat_info.get("is_forum", False): return None existing = get_topic_for_phone(bot_state, chat_id, phone) if existing is not None: return existing # Create new topic with initial name = phone number try: topic = await context.bot.create_forum_topic( chat_id=chat_id, name=phone, ) except Exception as e: print(f"Failed to create forum topic for {phone} in chat {chat_id}: {e}") return None topic_id = topic.message_thread_id set_topic_for_phone(bot_state, chat_id, phone, topic_id) save_topic_mapping(bot_state.config["topic_mapping_file"], bot_state.topic_mapping) return topic_id async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text( "Hello! I am an SMS gateway bot.\n" "- I will forward SMS messages according to the configured rules.\n" "- Authorized users can send SMS using /send." ) async def send_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: bot_state: BotState = context.application.bot_data["state"] user = update.effective_user if not is_allowed_user(bot_state.config, user.id): await update.message.reply_text("You are not authorized to send SMS.") return # /send with args: /send phone message... if context.args: phone_raw = context.args[0] message_text = " ".join(context.args[1:]).strip() if not message_text: await update.message.reply_text( "Usage: /send <phone> <message text>" ) return phone = normalize_phone(phone_raw, bot_state.config["default_country_code"]) # Save pending send state bot_state.pending_send[user.id] = { "phone": phone, "text": message_text, "origin": "shortcut", } keyboard = InlineKeyboardMarkup([ [ InlineKeyboardButton("Send", callback_data="confirm_send"), InlineKeyboardButton("Cancel", callback_data="cancel_send"), ] ]) await update.message.reply_text( f"Send SMS to {phone} with the following text?\n\n" f"{message_text}", reply_markup=keyboard, ) else: # Interactive mode bot_state.pending_send[user.id] = { "step": "ask_phone", } await update.message.reply_text( "Please enter the recipient phone number.\n" "You can include country code (e.g. +123456789);\n" "if you omit it, the default country code will be used." ) async def text_message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """ - Handle interactive /send (ask phone / ask text). - Handle replies in topics -> send SMS to mapped phone. """ bot_state: BotState = context.application.bot_data["state"] message = update.effective_message user = update.effective_user chat = update.effective_chat if user is None or message is None: return # 1) Topic reply -> send SMS if chat.type in ("supergroup", "group") and message.message_thread_id is not None: # Only allow authorized users if not is_allowed_user(bot_state.config, user.id): return phone = get_phone_for_topic(bot_state, chat.id, message.message_thread_id) if phone: sms_text = message.text or "" if not sms_text.strip(): return success, output = send_sms_via_gammu( bot_state.config["gammu_command"], phone, sms_text, ) if success: await message.reply_text( f"SMS sent to {phone}.\n" f"(gammu output: {output.strip() or 'OK'})" ) else: await message.reply_text( f"Failed to send SMS to {phone}.\n" f"(gammu output: {output.strip()})" ) return # 2) Interactive /send state machine state = bot_state.pending_send.get(user.id) if not state: # Not in interactive sending return # Ignore commands during interactive flow (they are handled elsewhere) if message.text and message.text.startswith("/"): return if state.get("step") == "ask_phone": phone_raw = (message.text or "").strip() if not phone_raw: await message.reply_text("Please enter a valid phone number, or /cancel.") return phone = normalize_phone(phone_raw, bot_state.config["default_country_code"]) state["phone"] = phone state["step"] = "ask_text" await message.reply_text( f"Recipient set to {phone}.\n" "Now please enter the SMS text." ) elif state.get("step") == "ask_text": sms_text = (message.text or "").strip() if not sms_text: await message.reply_text("SMS text cannot be empty. Please enter again.") return state["text"] = sms_text state["step"] = "confirm" keyboard = InlineKeyboardMarkup([ [ InlineKeyboardButton("Send", callback_data="confirm_send"), InlineKeyboardButton("Cancel", callback_data="cancel_send"), ] ]) await message.reply_text( f"Send SMS to {state['phone']} with the following text?\n\n" f"{sms_text}", reply_markup=keyboard, ) async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: bot_state: BotState = context.application.bot_data["state"] user = update.effective_user if user and user.id in bot_state.pending_send: bot_state.pending_send.pop(user.id, None) await update.message.reply_text("SMS sending cancelled.") else: await update.message.reply_text("No pending SMS sending session.") async def callback_query_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: bot_state: BotState = context.application.bot_data["state"] query = update.callback_query await query.answer() user = query.from_user state = bot_state.pending_send.get(user.id) if not state: await query.edit_message_text("No active SMS sending session.") return if query.data == "cancel_send": bot_state.pending_send.pop(user.id, None) await query.edit_message_text("Cancelled.") return if query.data == "confirm_send": phone = state.get("phone") text = state.get("text") if not phone or not text: await query.edit_message_text("Invalid state. Please try /send again.") bot_state.pending_send.pop(user.id, None) return success, output = send_sms_via_gammu( bot_state.config["gammu_command"], phone, text, ) bot_state.pending_send.pop(user.id, None) if success: await query.edit_message_text( f"SMS sent to {phone}.\n" f"(gammu output: {output.strip() or 'OK'})" ) else: await query.edit_message_text( f"Failed to send SMS to {phone}.\n" f"(gammu output: {output.strip()})" ) # ----------------------- # Gammu inbox polling job # ----------------------- async def poll_inbox_job(context: ContextTypes.DEFAULT_TYPE) -> None: """ Periodically: - Scan inbox directory for new files. - Parse SMS. - Group *new* SMS by sender & network timestamp: * For each sender, messages whose network-time difference <= 5 seconds are merged into one forwarded Telegram message. * This avoids merging old messages that were sent far apart but discovered in the same polling cycle after a long downtime. """ bot_state: BotState = context.application.bot_data["state"] inbox_path = Path(bot_state.config["inbox_path"]) if not inbox_path.exists(): print(f"Inbox path {inbox_path} does not exist.") return all_files = sorted(inbox_path.glob("IN*.txt")) new_sms: List[Dict[str, Any]] = [] new_processed = False # 1) Scan for new files and parse them for file_path in all_files: fname = str(file_path) if fname in bot_state.processed_files: continue sms = parse_gammu_inbox_file( file_path, bot_state.config["default_country_code"], ) # Mark as processed no matter parsing succeeds or not bot_state.processed_files.add(fname) new_processed = True if not sms: continue new_sms.append(sms) if new_processed: save_processed_files(bot_state.processed_files_file, bot_state.processed_files) # Nothing new if not new_sms: return # 2) Prepare time zones network_tz_name = bot_state.config.get("network_timezone") local_tz_name = bot_state.config.get("local_timezone") network_tz = ZoneInfo(network_tz_name) if network_tz_name else None local_tz = ZoneInfo(local_tz_name) if local_tz_name else None # Attach parsed datetime and group by sender per_sender: Dict[str, List[Dict[str, Any]]] = {} for sms in new_sms: sender = sms["from"] sent_str = sms.get("sent") sent_dt = None if sent_str and network_tz: try: # "YYYY-MM-DD HH:MM:SS" dt_naive = datetime.strptime(sent_str, "%Y-%m-%d %H:%M:%S") sent_dt = dt_naive.replace(tzinfo=network_tz) except Exception as e: print(f"Failed to parse sent time '{sent_str}' for SMS from {sender}: {e}") sms["sent_dt"] = sent_dt per_sender.setdefault(sender, []).append(sms) # 3) For each sender, sort by network time and group messages whose # time difference <= 5 seconds into one forwarded message. merge_threshold = timedelta(seconds=5) for sender, messages in per_sender.items(): # Sort messages by sent_dt; messages without sent_dt go last def sort_key(m: Dict[str, Any]): dt = m.get("sent_dt") if dt is None: # Put undated messages at the end; use a large datetime as fallback return datetime.max.replace(tzinfo=network_tz or ZoneInfo("UTC")) return dt messages.sort(key=sort_key) if not messages: continue groups: List[List[Dict[str, Any]]] = [] current_group: List[Dict[str, Any]] = [messages[0]] last_dt: Optional[datetime] = messages[0].get("sent_dt") for sms in messages[1:]: dt = sms.get("sent_dt") # If either timestamp missing, start a new group (be conservative) if last_dt is None or dt is None: groups.append(current_group) current_group = [sms] last_dt = dt continue # Compare network times if dt - last_dt <= merge_threshold: current_group.append(sms) last_dt = dt else: groups.append(current_group) current_group = [sms] last_dt = dt # Flush last group if current_group: groups.append(current_group) # 4) Send each group as one Telegram message for group in groups: # Representative SMS for time info = first in group rep_sms = group[0] rep_dt: Optional[datetime] = rep_sms.get("sent_dt") raw_sent_str = rep_sms.get("sent") network_time_str = raw_sent_str or "Unknown" local_time_str = None if rep_dt is not None: try: # rep_dt already has network timezone network_time_str = rep_dt.strftime("%Y-%m-%d %H:%M:%S %Z") if local_tz is not None: dt_local = rep_dt.astimezone(local_tz) else: dt_local = rep_dt.astimezone() # system local timezone local_time_str = dt_local.strftime("%Y-%m-%d %H:%M:%S %Z") except Exception as e: print(f"Failed to convert representative time for {sender}: {e}") # Combine texts; you原来是 "".join,这里保持一致 combined_text = "".join(s["text"] for s in group) if local_time_str: forward_body = ( "📩 New SMS\n" f"From: {sender}\n" f"Time ({network_tz_name}): {network_time_str}\n" f"Time ({local_tz_name}): {local_time_str}\n\n" f"{combined_text}" ) else: fallback_time = network_time_str or "Unknown" forward_body = ( "📩 New SMS\n" f"From: {sender}\n" f"Time: {fallback_time}\n\n" f"{combined_text}" ) # Match forwarding rules per *combined* content rules = find_matching_rules(bot_state.config, combined_text) if not rules: continue # Send to all matching chats, each with its own topic (if supported) for rule in rules: chat_id = rule["chat_id"] chat_info = await ensure_chat_info_cached(bot_state, context, chat_id) message_thread_id = None if chat_info.get("is_forum", False): topic_id = await get_or_create_topic_id( bot_state, context, chat_id, sender, ) message_thread_id = topic_id try: await context.bot.send_message( chat_id=chat_id, text=forward_body, message_thread_id=message_thread_id, ) except Exception as e: print(f"Failed to forward SMS to chat {chat_id}: {e}") # ----------------------- # Main # ----------------------- def main() -> None: config = load_config(CONFIG_PATH) bot_state = BotState(config) application = Application.builder().token(config["telegram_bot_token"]).build() application.bot_data["state"] = bot_state # Command handlers application.add_handler(CommandHandler("start", start_command)) application.add_handler(CommandHandler("send", send_command)) application.add_handler(CommandHandler("cancel", cancel_command)) # Callback query (for confirm/cancel send) application.add_handler(CallbackQueryHandler(callback_query_handler)) # Text handler (topic replies + interactive /send) application.add_handler( MessageHandler(filters.TEXT & ~filters.COMMAND, text_message_handler) ) # JobQueue job_queue = application.job_queue if job_queue is None: raise RuntimeError( 'JobQueue is not available. Install with:\n' ' pip install "python-telegram-bot[job-queue]==20.8"' ) job_queue.run_repeating(poll_inbox_job, interval=2.0, first=2.0) print("Bot is starting...") application.run_polling() if __name__ == "__main__": main() #p-7026874-h-7-configjson-97. config.json 示例与说明 脚本所有行为都通过 config.json 控制,文件放在和 bot.py 同一个目录下: config.json { "telegram_bot_token": "1234567890:ABCDEF_your_bot_token_here", "allowed_user_ids": [123456789], "default_country_code": "+86", "inbox_path": "/var/spool/gammu/inbox", "gammu_command": "gammu-smsd-inject", "forwarding_rules": [ { "keyword": "*", "chat_id": -1001234567890123 }, { "keyword": "BANK", "chat_id": -1001234567890123 }, { "keyword": "code", "chat_id": 123456789 } ], "topic_mapping_file": "topics.json", "processed_files_file": "processed_files.json", "network_timezone": "Asia/Shanghai", "local_timezone": "America/New_York" } 逐项解释一下: telegram_bot_token 从 BotFather 拿到的 bot token。 allowed_user_ids 允许发短信、用 /send 的 Telegram 用户 ID(数字)。 可以使用https://t.me/userinfobot 获取自己的 user ID。 default_country_code 默认国家码,比如国内号就用 +86 。 如果短信号码本身没有以 + 或 00 开头,脚本会自动前面加上这个前缀。 inbox_path gammu-smsd files backend 的 InboxPath,一般是 /var/spool/gammu/inbox 。 gammu_command 用来发送短信的命令,这里用 gammu-smsd-inject : 实际执行类似: gammu-smsd-inject TEXT <number> -text "..." forwarding_rules 短信转发规则数组,每条包含: { "keyword": "*", "chat_id": -1001234567890123 } keyword :不区分大小写的关键词, "*" 表示匹配所有短信; chat_id :要转发到的目标 chat,可以是: 负数:supergroup / 群 / channel 的 ID(注意要让 bot 在群里); 正数:私聊的 user ID。 获取: 给自己群匿名权限 然后转发消息给 bot https://t.me/userinfobot 机器人会给你群ID 记得完成这步之后要把匿名权限关闭 不然机器人无法判断你是否为授权用户 如果一条短信同时匹配多条规则,会转发到所有匹配 chat。 topic_mapping_file 用来持久化保存「chat_id → (phone ↔ topic_id) 映射」的 JSON 文件。 这样你可以在 TG 里把 topic 重命名成「某平台验证码」「银行×××」之类,脚本仍然认得 topic 和号码是一对。 processed_files_file 保存「已经处理过的 IN*.txt 文件列表」,防止重启后重复转发历史短信。 network_timezone / local_timezone network_timezone :短信中心所在时区,比如中国号用 "Asia/Shanghai" ; local_timezone :你自己的本地时区,比如你机器在美东: "America/New_York" 。 脚本会把从文件名解析出来的时间视为 network 时区,然后再转换到 local 时区,这样 Telegram 里会显示两行: Time (network): 2025-11-18 01:02:35 CST Time (local): 2025-11-17 12:02:35 EST #p-7026874-h-8-108. 机器人使用说明(行为简介) 发新短信: /send 进入交互式: 机器人问你收件人号码; 机器人问短信内容; Inline button「Send / Cancel」确认后真正发出。 /send <phone> <message...> 直接一步到位,仍然会有一次确认,号码会自动加默认国家码(除非你自己写了 + 或 00 )。 Edit: 所有支持标准AT协议的dongle都可以使用 比如 https://www.uscardforum.com/t/topic/373749/5 所需设备 树莓派 x 1 (我的是4B2g版本) 4G模块minipcie转USB转接板 (25¥) 4G天线 x 1 (2.5¥) IPEX转SMA连接线 x 1 (2¥) 4G模块 x 1 (推荐咸鱼买移远EC20,50¥) /uploads/short-url/4BJi7nDxXBvrq8nFgL0rItqjxfc.jpeg?dl=1 https://blog.csdn.net/tiaga/article/details/127975344 spktr: 另外如果希望dongle也能打电话的话,建议买移远的EG25-G模块(当时在国内咸鱼买大概70人民币一块),这个模块过了美国三大运营商的认证,因此支持他们的VOLTE 我贴子里选的这个主要是找到的支持美国所有4G频段的就这个最便宜 要更便宜的话甚至有10人民币的dongle
效果展示 /uploads/short-url/bCfrQk6lztA5BMnX4HJcO4vy7oD.png?dl=1 Topic可以随意改名 /uploads/short-url/lUu6beVxtzOTO6s7YKafnQMGg4F.png?dl=1
如果这个device能pass给docker,那应该在nas里也可以跑了 楼主
可以 mount路径就行
买个这种垃圾安卓插着卡转发不就行了 https://www.ebay.com/itm/146811244215 https://www.ebay.com/itm/146811244215 Up for sale is a SKY Devices Elite G63 - 32GB (GSM Unlocked) Dual SIM Smartphone. - Original SIM Card(s). - Back Cover. What is NOT Included - Touch Screen. - Headphone Jack.
电池啊 照着所谓的20充电 80停止 搞了个脚本这样子放 也撑不到一年
我记得现在手机充满以后都是充电线直接供电,电池不会供电? 感觉长期使用可能20/80会更伤电池,因为真的电池一直在循环充放电
一直插着就行了,你就当做这个设备没有电池
nifury: 记得现在手机充满以后都是充电线直接供电,电池不会供电? 一直插着也试过 都没撑过半年……
联通没有 移动无忧行 平替吗
可不可以用树莓派这样搞,再搭配上telegram就爽了 https://www.uscardforum.com/t/topic/373749/5 /c/shopping/wireless-services/44 #p-5521652-h-1所需设备 树莓派 x 1 (我的是4B2g版本) 4G模块minipcie转USB转接板 (25¥) 4G天线 x 1 (2.5¥) IPEX转SMA连接线 x 1 (2¥) 4G模块 x 1 (推荐咸鱼买移远EC20,50¥) /uploads/short-url/4BJi7nDxXBvrq8nFgL0rItqjxfc.jpeg?dl=1 https://blog.csdn.net/tiaga/article/details/127975344
没有吧,我24小时插电快两年了也没问题啊
我旧手机 24 小时插电限制 70% 电量五六年了还没挂
树莓派的供电可能不够,需要另外接一个外接供电的USB Hub才能带的动dongle;我是用的ebay上买的Dell/hp小主机 (比如 https://www.ebay.com/itm/186763275404 , 最便宜的大概100USD不到 ),性能比树莓派好一些,功耗也还可控。
另外如果希望dongle也能打电话的话,建议买移远的EG25-G模块(当时在国内咸鱼买大概70人民币一块),这个模块过了美国三大运营商的认证,因此支持他们的VOLTE
好深奥的帖子,原理是本来手机卡要插在手机上一直充电,现在是一直插在一个叫“懂乐 ”的设备里一直通电吗?
这些LTE模块本质就是一个运行Android/嵌入式系统的手机(比如EG25似乎是基于高通骁龙410,RM500x是基于骁龙855),跟手机的区别大概只是没有屏幕
pixel 插了好几年了 电池还正常
联通没有
国内插了个这个,因为保号套餐自带200M流量,没弄wifi模块,短信转发到Bark,现在ios能自动读推送里的验证码了,挺好,快两年了 https://www.chenxublog.com/2022/10/28/19-9-sms-forwarding-air780e-esp32c3.html
用任何支持标准AT协议的模块都可以 模块淘宝/ebay上一大堆 具体哪些能用得自己测试 :\
这个没法美国用吧 我看了频段几乎所有美国的频段他都不支持 怕信号会很差 我选我这个主要是看了各种dongle的频段 只有这个频段是最全的
我的 没仔细读题 我是直接插在国内的
我现在还是用的iphone13 有卡槽比啥都强
我甚至找到一个教程教你刷linux的
我也不知道啊 我每个用来接短信的手机都活不过1年
android? 试试iphone,我用iphone x一直插着电,现在还好好的
是的 安卓 毕竟要挂壁 而且我不用苹果 用不了苹果的短信同步
你把电池拔了试试?手机本身不插电池也可以正常开机,只是有些系统会配置成尝试关机。 鼓起来了刚好也方便拆开了。
/uploads/short-url/zkf6f6Sxv4tHvmdM7q2AB8c9ikj.jpeg?dl=1 推荐买这个 成本低,多搜一下还有十几块钱的。
这种也可以短信转发吗
这种不行 得用他们的网页
那太可惜了 uhhhh: 国内手机号还要长期收验证码 咱们这种需求太小众,除了手机没在网上找到可以直接使用的软硬件一体的服务
淘宝上有一堆的 但是都要走他们的服务器 你的短信会先被发送到他们的服务器再转发给你 他们跑路了你手上的设备就是一个砖块了 不过这是我几年前搜的 如果现在搜不到了 说明他们已经跑路了 也有更贵更贵的方案 猫池
就是单纯想搞个类似树莓派一样的东西,有一键式部署的软件服务,就像nas是自己搞一个私密云 但不管咋样现在手机先用着,相信 后人 未来自己的智慧 uhhhh: 猫池 瞄了一眼,这个真强
我这个方案算是相对简单的部署方案了? 假设你已经有一台linux机器 买个设备 插上sim卡 照教程做一下 (实际要做的步骤很简单 其他都是检查你过程有没有出错的而已) 就可以telegram上控制了
不用,这个也能刷,推送到 telegram bark 啥的。。
可以,参考链接 https://post.smzdm.com/p/arq7xpzw/
看了一下这个教程 那看起来跟我的方案还是差了几点: 他依然有电池 我这个电池杀手他活不了多久的 他这个里面是安卓系统 所以他的方案只能转发 没法发送短信 虽然这个也有其他软件能解决 (我之前买的软件好像跑路了) 没法发送短信遇到腾讯之类的大毒瘤就麻烦了 腾讯经常要求不是收验证码 而是要求你发送特定短信内容到特定号码 而且你这个动手能力要好强 我手残做不来 看来会动手的可以省更多钱
那个文章仅供参考,实际上是刷 op 的,而且也没电池,就是我发的那个 20 块钱左右的设备。
op? zszs
https://www.kdocs.cn/l/cedSRLMc11mP /uploads/short-url/dNgl09NckXXcgR4dQP2iKAnppbV.png?dl=1 https://zhichao.org/posts/4dcba8 https://zhichao.org/posts/4dcba8 在这里,我们专注于分享前端、VPS、NAS、以及 Docker 应用的相关内容。无论你是一个技术爱好者、开发者,还是IT专业人士,我们都希望为你提供有价值的知识和实践经验。 /uploads/short-url/sx2Az5I7ciBOC2PodhrTmivEGae.jpeg?dl=1
太狂了 不过刷openwrt的话连wifi又要另外研究怎么设置了
发短信是按美国漫游计费?还是国内发短信计费?
当然是美国漫游计费了 除非你把设备放在国内
动手能力不强的可以用钱来凑, 这个GL.iNet GL-X3000自带转发 https://www.amazon.com/GL-iNet-GL-X3000-Multi-WAN-Detachable-WireGuard/dp/B0C5RCQ8N5/ref=sr_1_2_sspa?tag=uscardforum-20
你这个需求买一个定时开关不就可以了吗 才20RMB 设置个每天充30分钟
电池BOOM 我也不知道为什么
我的苹果6,一直插电iCloud转发,前面用了两年都没事,但实在太慢了,换了一个,半年就鼓包了。 现在老老实实无忧行
要是我是移动我也不用这么麻烦 直接无忧行了
太复杂啦,我来个简单的方案:碎屏儿童手表
大赞,非常详细
这个随身WiFi支持美国的频段吗?