839 lines
26 KiB
Python
839 lines
26 KiB
Python
import smtplib
|
||
import os
|
||
import time
|
||
import traceback
|
||
from abc import ABC, abstractmethod
|
||
from datetime import datetime
|
||
from email.message import EmailMessage
|
||
from queue import Empty, Queue
|
||
from threading import Thread, current_thread
|
||
from typing import Any, TypeVar, cast
|
||
from collections.abc import Callable
|
||
|
||
from vnpy.event import Event, EventEngine
|
||
from .app import BaseApp
|
||
from .event import (
|
||
EVENT_TICK,
|
||
EVENT_ORDER,
|
||
EVENT_TRADE,
|
||
EVENT_POSITION,
|
||
EVENT_ACCOUNT,
|
||
EVENT_CONTRACT,
|
||
EVENT_LOG,
|
||
EVENT_QUOTE
|
||
)
|
||
from .gateway import BaseGateway
|
||
from .object import (
|
||
CancelRequest,
|
||
LogData,
|
||
OrderRequest,
|
||
QuoteData,
|
||
QuoteRequest,
|
||
SubscribeRequest,
|
||
HistoryRequest,
|
||
OrderData,
|
||
BarData,
|
||
TickData,
|
||
TradeData,
|
||
PositionData,
|
||
AccountData,
|
||
ContractData,
|
||
Exchange
|
||
)
|
||
from .setting import SETTINGS
|
||
from .utility import TRADER_DIR, load_json, save_json
|
||
from .converter import OffsetConverter
|
||
from .logger import logger, DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||
from .locale import _
|
||
from .wechat import (
|
||
Credentials,
|
||
SessionExpired,
|
||
WeixinError,
|
||
send_text,
|
||
)
|
||
|
||
|
||
EngineType = TypeVar("EngineType", bound="BaseEngine")
|
||
|
||
|
||
class BaseEngine(ABC):
|
||
"""
|
||
Abstract class for implementing a function engine.
|
||
"""
|
||
|
||
@abstractmethod
|
||
def __init__(
|
||
self,
|
||
main_engine: "MainEngine",
|
||
event_engine: EventEngine,
|
||
engine_name: str,
|
||
) -> None:
|
||
""""""
|
||
self.main_engine: MainEngine = main_engine
|
||
self.event_engine: EventEngine = event_engine
|
||
self.engine_name: str = engine_name
|
||
|
||
def close(self) -> None:
|
||
""""""
|
||
return
|
||
|
||
|
||
class MainEngine:
|
||
"""
|
||
Acts as the core of the trading platform.
|
||
"""
|
||
|
||
def __init__(self, event_engine: EventEngine | None = None) -> None:
|
||
""""""
|
||
if event_engine:
|
||
self.event_engine: EventEngine = event_engine
|
||
else:
|
||
self.event_engine = EventEngine()
|
||
self.event_engine.start()
|
||
|
||
self.gateways: dict[str, BaseGateway] = {}
|
||
self.engines: dict[str, BaseEngine] = {}
|
||
self.apps: dict[str, BaseApp] = {}
|
||
self.exchanges: list[Exchange] = []
|
||
|
||
os.chdir(TRADER_DIR) # Change working directory
|
||
self.init_engines() # Initialize function engines
|
||
|
||
def add_engine(self, engine_class: type[EngineType]) -> EngineType:
|
||
"""
|
||
Add function engine.
|
||
"""
|
||
engine: EngineType = engine_class(self, self.event_engine) # type: ignore
|
||
self.engines[engine.engine_name] = engine
|
||
return engine
|
||
|
||
def add_gateway(self, gateway_class: type[BaseGateway], gateway_name: str = "") -> BaseGateway:
|
||
"""
|
||
Add gateway.
|
||
"""
|
||
# Use default name if gateway_name not passed
|
||
if not gateway_name:
|
||
gateway_name = gateway_class.default_name
|
||
|
||
gateway: BaseGateway = gateway_class(self.event_engine, gateway_name)
|
||
self.gateways[gateway_name] = gateway
|
||
|
||
# Add gateway supported exchanges into engine
|
||
for exchange in gateway.exchanges:
|
||
if exchange not in self.exchanges:
|
||
self.exchanges.append(exchange)
|
||
|
||
return gateway
|
||
|
||
def add_app(self, app_class: type[BaseApp]) -> BaseEngine:
|
||
"""
|
||
Add app.
|
||
"""
|
||
app: BaseApp = app_class()
|
||
self.apps[app.app_name] = app
|
||
|
||
engine: BaseEngine = self.add_engine(app.engine_class)
|
||
return engine
|
||
|
||
def init_engines(self) -> None:
|
||
"""
|
||
Init all engines.
|
||
"""
|
||
self.add_engine(LogEngine)
|
||
|
||
oms_engine: OmsEngine = self.add_engine(OmsEngine)
|
||
self.get_tick: Callable[[str], TickData | None] = oms_engine.get_tick
|
||
self.get_order: Callable[[str], OrderData | None] = oms_engine.get_order
|
||
self.get_trade: Callable[[str], TradeData | None] = oms_engine.get_trade
|
||
self.get_position: Callable[[str], PositionData | None] = oms_engine.get_position
|
||
self.get_account: Callable[[str], AccountData | None] = oms_engine.get_account
|
||
self.get_contract: Callable[[str], ContractData | None] = oms_engine.get_contract
|
||
self.get_quote: Callable[[str], QuoteData | None] = oms_engine.get_quote
|
||
self.get_all_ticks: Callable[[], list[TickData]] = oms_engine.get_all_ticks
|
||
self.get_all_orders: Callable[[], list[OrderData]] = oms_engine.get_all_orders
|
||
self.get_all_trades: Callable[[], list[TradeData]] = oms_engine.get_all_trades
|
||
self.get_all_positions: Callable[[], list[PositionData]] = oms_engine.get_all_positions
|
||
self.get_all_accounts: Callable[[], list[AccountData]] = oms_engine.get_all_accounts
|
||
self.get_all_contracts: Callable[[], list[ContractData]] = oms_engine.get_all_contracts
|
||
self.get_all_quotes: Callable[[], list[QuoteData]] = oms_engine.get_all_quotes
|
||
self.get_all_active_orders: Callable[[], list[OrderData]] = oms_engine.get_all_active_orders
|
||
self.get_all_active_quotes: Callable[[], list[QuoteData]] = oms_engine.get_all_active_quotes
|
||
self.update_order_request: Callable[[OrderRequest, str, str], None] = oms_engine.update_order_request
|
||
self.convert_order_request: Callable[[OrderRequest, str, bool, bool], list[OrderRequest]] = oms_engine.convert_order_request
|
||
self.get_converter: Callable[[str], OffsetConverter | None] = oms_engine.get_converter
|
||
|
||
self.add_engine(EmailEngine)
|
||
self.add_engine(WechatEngine)
|
||
|
||
def write_log(self, msg: str, source: str = "MainEngine") -> None:
|
||
"""
|
||
Put log event with specific message.
|
||
"""
|
||
log: LogData = LogData(msg=msg, gateway_name=source)
|
||
event: Event = Event(EVENT_LOG, log)
|
||
self.event_engine.put(event)
|
||
|
||
def send_notification(self, content: str, subject: str | None = None) -> None:
|
||
"""
|
||
Push notification through all configured channels.
|
||
"""
|
||
if subject is None:
|
||
subject = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
email_engine: EmailEngine = cast(EmailEngine, self.get_engine("email"))
|
||
email_engine.send_email(subject, content)
|
||
|
||
wechat_engine: WechatEngine = cast(WechatEngine, self.get_engine("wechat"))
|
||
wechat_engine.send_wechat(f"{subject}\n{content}")
|
||
|
||
def get_gateway(self, gateway_name: str) -> BaseGateway | None:
|
||
"""
|
||
Return gateway object by name.
|
||
"""
|
||
gateway: BaseGateway | None = self.gateways.get(gateway_name, None)
|
||
if not gateway:
|
||
self.write_log(_("找不到底层接口:{}").format(gateway_name))
|
||
return gateway
|
||
|
||
def get_engine(self, engine_name: str) -> BaseEngine | None:
|
||
"""
|
||
Return engine object by name.
|
||
"""
|
||
engine: BaseEngine | None = self.engines.get(engine_name, None)
|
||
if not engine:
|
||
self.write_log(_("找不到引擎:{}").format(engine_name))
|
||
return engine
|
||
|
||
def get_default_setting(self, gateway_name: str) -> dict[str, str | bool | int | float] | None:
|
||
"""
|
||
Get default setting dict of a specific gateway.
|
||
"""
|
||
gateway: BaseGateway | None = self.get_gateway(gateway_name)
|
||
if gateway:
|
||
return gateway.get_default_setting()
|
||
return None
|
||
|
||
def get_all_gateway_names(self) -> list[str]:
|
||
"""
|
||
Get all names of gateway added in main engine.
|
||
"""
|
||
return list(self.gateways.keys())
|
||
|
||
def get_all_apps(self) -> list[BaseApp]:
|
||
"""
|
||
Get all app objects.
|
||
"""
|
||
return list(self.apps.values())
|
||
|
||
def get_all_exchanges(self) -> list[Exchange]:
|
||
"""
|
||
Get all exchanges.
|
||
"""
|
||
return self.exchanges
|
||
|
||
def connect(self, setting: dict, gateway_name: str) -> None:
|
||
"""
|
||
Start connection of a specific gateway.
|
||
"""
|
||
gateway: BaseGateway | None = self.get_gateway(gateway_name)
|
||
if gateway:
|
||
self.write_log(_("连接登录 -> {}").format(gateway_name))
|
||
|
||
gateway.connect(setting)
|
||
|
||
def subscribe(self, req: SubscribeRequest, gateway_name: str) -> None:
|
||
"""
|
||
Subscribe tick data update of a specific gateway.
|
||
"""
|
||
gateway: BaseGateway | None = self.get_gateway(gateway_name)
|
||
if gateway:
|
||
self.write_log(_("订阅行情 -> {}:{}").format(gateway_name, req))
|
||
|
||
gateway.subscribe(req)
|
||
|
||
def send_order(self, req: OrderRequest, gateway_name: str) -> str:
|
||
"""
|
||
Send new order request to a specific gateway.
|
||
"""
|
||
gateway: BaseGateway | None = self.get_gateway(gateway_name)
|
||
if gateway:
|
||
self.write_log(_("委托下单 -> {}:{}").format(gateway_name, req))
|
||
|
||
return gateway.send_order(req)
|
||
else:
|
||
return ""
|
||
|
||
def cancel_order(self, req: CancelRequest, gateway_name: str) -> None:
|
||
"""
|
||
Send cancel order request to a specific gateway.
|
||
"""
|
||
gateway: BaseGateway | None = self.get_gateway(gateway_name)
|
||
if gateway:
|
||
self.write_log(_("委托撤单 -> {}:{}").format(gateway_name, req))
|
||
|
||
gateway.cancel_order(req)
|
||
|
||
def send_quote(self, req: QuoteRequest, gateway_name: str) -> str:
|
||
"""
|
||
Send new quote request to a specific gateway.
|
||
"""
|
||
gateway: BaseGateway | None = self.get_gateway(gateway_name)
|
||
if gateway:
|
||
self.write_log(_("报价下单 -> {}:{}").format(gateway_name, req))
|
||
|
||
return gateway.send_quote(req)
|
||
else:
|
||
return ""
|
||
|
||
def cancel_quote(self, req: CancelRequest, gateway_name: str) -> None:
|
||
"""
|
||
Send cancel quote request to a specific gateway.
|
||
"""
|
||
gateway: BaseGateway | None = self.get_gateway(gateway_name)
|
||
if gateway:
|
||
self.write_log(_("报价撤单 -> {}:{}").format(gateway_name, req))
|
||
|
||
gateway.cancel_quote(req)
|
||
|
||
def query_history(self, req: HistoryRequest, gateway_name: str) -> list[BarData]:
|
||
"""
|
||
Query bar history data from a specific gateway.
|
||
"""
|
||
gateway: BaseGateway | None = self.get_gateway(gateway_name)
|
||
if gateway:
|
||
self.write_log(_("查询K线 -> {}:{}").format(gateway_name, req))
|
||
|
||
return gateway.query_history(req)
|
||
else:
|
||
return []
|
||
|
||
def close(self) -> None:
|
||
"""
|
||
Make sure every gateway and app is closed properly before
|
||
programme exit.
|
||
"""
|
||
# Stop event engine first to prevent new timer event.
|
||
self.event_engine.stop()
|
||
|
||
for engine in self.engines.values():
|
||
engine.close()
|
||
|
||
for gateway in self.gateways.values():
|
||
gateway.close()
|
||
|
||
|
||
class LogEngine(BaseEngine):
|
||
"""
|
||
Provides log event output function.
|
||
"""
|
||
|
||
level_map: dict[int, str] = {
|
||
DEBUG: "DEBUG",
|
||
INFO: "INFO",
|
||
WARNING: "WARNING",
|
||
ERROR: "ERROR",
|
||
CRITICAL: "CRITICAL",
|
||
}
|
||
|
||
def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None:
|
||
""""""
|
||
super().__init__(main_engine, event_engine, "log")
|
||
|
||
self.active = SETTINGS["log.active"]
|
||
|
||
self.register_log(EVENT_LOG)
|
||
|
||
def process_log_event(self, event: Event) -> None:
|
||
"""Process log event"""
|
||
if not self.active:
|
||
return
|
||
|
||
log: LogData = event.data
|
||
level: str | int = self.level_map.get(log.level, log.level)
|
||
logger.bind(gateway_name=log.gateway_name).log(level, log.msg)
|
||
|
||
def register_log(self, event_type: str) -> None:
|
||
"""Register log event handler"""
|
||
self.event_engine.register(event_type, self.process_log_event)
|
||
|
||
|
||
class OmsEngine(BaseEngine):
|
||
"""
|
||
Provides order management system function.
|
||
"""
|
||
|
||
def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None:
|
||
""""""
|
||
super().__init__(main_engine, event_engine, "oms")
|
||
|
||
self.ticks: dict[str, TickData] = {}
|
||
self.orders: dict[str, OrderData] = {}
|
||
self.trades: dict[str, TradeData] = {}
|
||
self.positions: dict[str, PositionData] = {}
|
||
self.accounts: dict[str, AccountData] = {}
|
||
self.contracts: dict[str, ContractData] = {}
|
||
self.quotes: dict[str, QuoteData] = {}
|
||
|
||
self.active_orders: dict[str, OrderData] = {}
|
||
self.active_quotes: dict[str, QuoteData] = {}
|
||
|
||
self.offset_converters: dict[str, OffsetConverter] = {}
|
||
|
||
self.register_event()
|
||
|
||
def register_event(self) -> None:
|
||
""""""
|
||
self.event_engine.register(EVENT_TICK, self.process_tick_event)
|
||
self.event_engine.register(EVENT_ORDER, self.process_order_event)
|
||
self.event_engine.register(EVENT_TRADE, self.process_trade_event)
|
||
self.event_engine.register(EVENT_POSITION, self.process_position_event)
|
||
self.event_engine.register(EVENT_ACCOUNT, self.process_account_event)
|
||
self.event_engine.register(EVENT_CONTRACT, self.process_contract_event)
|
||
self.event_engine.register(EVENT_QUOTE, self.process_quote_event)
|
||
|
||
def process_tick_event(self, event: Event) -> None:
|
||
""""""
|
||
tick: TickData = event.data
|
||
self.ticks[tick.vt_symbol] = tick
|
||
|
||
def process_order_event(self, event: Event) -> None:
|
||
""""""
|
||
order: OrderData = event.data
|
||
self.orders[order.vt_orderid] = order
|
||
|
||
# If order is active, then update data in dict.
|
||
if order.is_active():
|
||
self.active_orders[order.vt_orderid] = order
|
||
# Otherwise, pop inactive order from in dict
|
||
elif order.vt_orderid in self.active_orders:
|
||
self.active_orders.pop(order.vt_orderid)
|
||
|
||
# Update to offset converter
|
||
converter: OffsetConverter | None = self.offset_converters.get(order.gateway_name, None)
|
||
if converter:
|
||
converter.update_order(order)
|
||
|
||
def process_trade_event(self, event: Event) -> None:
|
||
""""""
|
||
trade: TradeData = event.data
|
||
self.trades[trade.vt_tradeid] = trade
|
||
|
||
# Update to offset converter
|
||
converter: OffsetConverter | None = self.offset_converters.get(trade.gateway_name, None)
|
||
if converter:
|
||
converter.update_trade(trade)
|
||
|
||
def process_position_event(self, event: Event) -> None:
|
||
""""""
|
||
position: PositionData = event.data
|
||
self.positions[position.vt_positionid] = position
|
||
|
||
# Update to offset converter
|
||
converter: OffsetConverter | None = self.offset_converters.get(position.gateway_name, None)
|
||
if converter:
|
||
converter.update_position(position)
|
||
|
||
def process_account_event(self, event: Event) -> None:
|
||
""""""
|
||
account: AccountData = event.data
|
||
self.accounts[account.vt_accountid] = account
|
||
|
||
def process_contract_event(self, event: Event) -> None:
|
||
""""""
|
||
contract: ContractData = event.data
|
||
self.contracts[contract.vt_symbol] = contract
|
||
|
||
# Initialize offset converter for each gateway
|
||
if contract.gateway_name not in self.offset_converters:
|
||
self.offset_converters[contract.gateway_name] = OffsetConverter(self)
|
||
|
||
def process_quote_event(self, event: Event) -> None:
|
||
""""""
|
||
quote: QuoteData = event.data
|
||
self.quotes[quote.vt_quoteid] = quote
|
||
|
||
# If quote is active, then update data in dict.
|
||
if quote.is_active():
|
||
self.active_quotes[quote.vt_quoteid] = quote
|
||
# Otherwise, pop inactive quote from in dict
|
||
elif quote.vt_quoteid in self.active_quotes:
|
||
self.active_quotes.pop(quote.vt_quoteid)
|
||
|
||
def get_tick(self, vt_symbol: str) -> TickData | None:
|
||
"""
|
||
Get latest market tick data by vt_symbol.
|
||
"""
|
||
return self.ticks.get(vt_symbol, None)
|
||
|
||
def get_order(self, vt_orderid: str) -> OrderData | None:
|
||
"""
|
||
Get latest order data by vt_orderid.
|
||
"""
|
||
return self.orders.get(vt_orderid, None)
|
||
|
||
def get_trade(self, vt_tradeid: str) -> TradeData | None:
|
||
"""
|
||
Get trade data by vt_tradeid.
|
||
"""
|
||
return self.trades.get(vt_tradeid, None)
|
||
|
||
def get_position(self, vt_positionid: str) -> PositionData | None:
|
||
"""
|
||
Get latest position data by vt_positionid.
|
||
"""
|
||
return self.positions.get(vt_positionid, None)
|
||
|
||
def get_account(self, vt_accountid: str) -> AccountData | None:
|
||
"""
|
||
Get latest account data by vt_accountid.
|
||
"""
|
||
return self.accounts.get(vt_accountid, None)
|
||
|
||
def get_contract(self, vt_symbol: str) -> ContractData | None:
|
||
"""
|
||
Get contract data by vt_symbol.
|
||
"""
|
||
return self.contracts.get(vt_symbol, None)
|
||
|
||
def get_quote(self, vt_quoteid: str) -> QuoteData | None:
|
||
"""
|
||
Get latest quote data by vt_orderid.
|
||
"""
|
||
return self.quotes.get(vt_quoteid, None)
|
||
|
||
def get_all_ticks(self) -> list[TickData]:
|
||
"""
|
||
Get all tick data.
|
||
"""
|
||
return list(self.ticks.values())
|
||
|
||
def get_all_orders(self) -> list[OrderData]:
|
||
"""
|
||
Get all order data.
|
||
"""
|
||
return list(self.orders.values())
|
||
|
||
def get_all_trades(self) -> list[TradeData]:
|
||
"""
|
||
Get all trade data.
|
||
"""
|
||
return list(self.trades.values())
|
||
|
||
def get_all_positions(self) -> list[PositionData]:
|
||
"""
|
||
Get all position data.
|
||
"""
|
||
return list(self.positions.values())
|
||
|
||
def get_all_accounts(self) -> list[AccountData]:
|
||
"""
|
||
Get all account data.
|
||
"""
|
||
return list(self.accounts.values())
|
||
|
||
def get_all_contracts(self) -> list[ContractData]:
|
||
"""
|
||
Get all contract data.
|
||
"""
|
||
return list(self.contracts.values())
|
||
|
||
def get_all_quotes(self) -> list[QuoteData]:
|
||
"""
|
||
Get all quote data.
|
||
"""
|
||
return list(self.quotes.values())
|
||
|
||
def get_all_active_orders(self) -> list[OrderData]:
|
||
"""
|
||
Get all active orders.
|
||
"""
|
||
return list(self.active_orders.values())
|
||
|
||
def get_all_active_quotes(self) -> list[QuoteData]:
|
||
"""
|
||
Get all active quotes.
|
||
"""
|
||
return list(self.active_quotes.values())
|
||
|
||
def update_order_request(self, req: OrderRequest, vt_orderid: str, gateway_name: str) -> None:
|
||
"""
|
||
Update order request to offset converter.
|
||
"""
|
||
converter: OffsetConverter | None = self.offset_converters.get(gateway_name, None)
|
||
if converter:
|
||
converter.update_order_request(req, vt_orderid)
|
||
|
||
def convert_order_request(
|
||
self,
|
||
req: OrderRequest,
|
||
gateway_name: str,
|
||
lock: bool,
|
||
net: bool = False
|
||
) -> list[OrderRequest]:
|
||
"""
|
||
Convert original order request according to given mode.
|
||
"""
|
||
converter: OffsetConverter | None = self.offset_converters.get(gateway_name, None)
|
||
if not converter:
|
||
return [req]
|
||
|
||
reqs: list[OrderRequest] = converter.convert_order_request(req, lock, net)
|
||
return reqs
|
||
|
||
def get_converter(self, gateway_name: str) -> OffsetConverter | None:
|
||
"""
|
||
Get offset converter object of specific gateway.
|
||
"""
|
||
return self.offset_converters.get(gateway_name, None)
|
||
|
||
|
||
class EmailEngine(BaseEngine):
|
||
"""
|
||
Provides email sending function.
|
||
"""
|
||
|
||
def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None:
|
||
""""""
|
||
super().__init__(main_engine, event_engine, "email")
|
||
|
||
self.thread: Thread = Thread(target=self.run)
|
||
self.queue: Queue[EmailMessage] = Queue()
|
||
self.active: bool = False
|
||
|
||
def send_email(self, subject: str, content: str, receiver: str | None = None) -> None:
|
||
""""""
|
||
# Start email engine when sending first email.
|
||
if not self.active:
|
||
self.start()
|
||
|
||
# Use default receiver if not specified.
|
||
if not receiver:
|
||
receiver = SETTINGS["email.receiver"]
|
||
|
||
msg: EmailMessage = EmailMessage()
|
||
msg["From"] = SETTINGS["email.sender"]
|
||
msg["To"] = receiver
|
||
msg["Subject"] = subject
|
||
msg.set_content(content)
|
||
|
||
self.queue.put(msg)
|
||
|
||
def run(self) -> None:
|
||
""""""
|
||
server: str = SETTINGS["email.server"]
|
||
port: int = SETTINGS["email.port"]
|
||
username: str = SETTINGS["email.username"]
|
||
password: str = SETTINGS["email.password"]
|
||
|
||
while self.active:
|
||
try:
|
||
msg: EmailMessage = self.queue.get(block=True, timeout=1)
|
||
|
||
try:
|
||
with smtplib.SMTP_SSL(server, port) as smtp:
|
||
smtp.login(username, password)
|
||
smtp.send_message(msg)
|
||
smtp.close()
|
||
except Exception:
|
||
log_msg: str = _("邮件发送失败: {}").format(traceback.format_exc())
|
||
self.main_engine.write_log(log_msg, "EmailEngine")
|
||
except Empty:
|
||
pass
|
||
|
||
def start(self) -> None:
|
||
""""""
|
||
self.active = True
|
||
self.thread.start()
|
||
|
||
def close(self) -> None:
|
||
""""""
|
||
if not self.active:
|
||
return
|
||
|
||
self.active = False
|
||
self.thread.join()
|
||
|
||
|
||
class WechatEngine(BaseEngine):
|
||
"""
|
||
Provides WeChat push notification function.
|
||
"""
|
||
|
||
setting_filename: str = "wechat_setting.json"
|
||
|
||
def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None:
|
||
""""""
|
||
super().__init__(main_engine, event_engine, "wechat")
|
||
|
||
self.creds: Credentials | None = None
|
||
self.user_id: str = ""
|
||
|
||
self.send_interval: int = 60
|
||
self.last_ts: float | None = None
|
||
|
||
self.pending_msgs: list[str] = []
|
||
|
||
self.queue: Queue[str] = Queue()
|
||
self.thread: Thread | None = None
|
||
self.active: bool = False
|
||
|
||
self.load_setting()
|
||
|
||
if self.creds and self.user_id:
|
||
self.activate()
|
||
|
||
def load_setting(self) -> None:
|
||
"""Load credentials and user info from json file."""
|
||
data: dict[str, Any] = load_json(self.setting_filename)
|
||
|
||
bot_id: str = data.get("bot_id") or ""
|
||
token: str = data.get("token") or ""
|
||
if bot_id and token:
|
||
base_url: str = data.get("base_url") or "https://ilinkai.weixin.qq.com"
|
||
self.creds = Credentials(
|
||
bot_id=bot_id,
|
||
token=token,
|
||
base_url=base_url,
|
||
)
|
||
else:
|
||
self.creds = None
|
||
|
||
self.user_id = str(data.get("user_id") or data.get("chat_id") or "")
|
||
|
||
send_interval: Any = data.get("send_interval")
|
||
if isinstance(send_interval, int) and send_interval >= 0:
|
||
self.send_interval = send_interval
|
||
|
||
def save_setting(self) -> None:
|
||
"""Persist current state back to json file."""
|
||
data: dict[str, Any] = {}
|
||
|
||
if self.creds:
|
||
data["bot_id"] = self.creds.bot_id
|
||
data["token"] = self.creds.token
|
||
data["base_url"] = self.creds.base_url
|
||
|
||
if self.user_id:
|
||
data["user_id"] = self.user_id
|
||
|
||
data["send_interval"] = self.send_interval
|
||
|
||
save_json(self.setting_filename, data)
|
||
|
||
def bind(self, creds: Credentials, user_id: str) -> None:
|
||
"""
|
||
Complete binding with QR-code credentials and user ID.
|
||
"""
|
||
self.deactivate()
|
||
|
||
self.creds = creds
|
||
self.user_id = user_id
|
||
|
||
self.save_setting()
|
||
self.activate()
|
||
|
||
def unbind(self) -> None:
|
||
"""Clear credentials and stop pushing."""
|
||
self.deactivate()
|
||
|
||
self.creds = None
|
||
self.user_id = ""
|
||
self.pending_msgs.clear()
|
||
|
||
self.save_setting()
|
||
|
||
def activate(self) -> None:
|
||
"""Start worker thread."""
|
||
self.start()
|
||
|
||
def deactivate(self) -> None:
|
||
"""Stop worker thread."""
|
||
self.stop()
|
||
|
||
def start(self) -> None:
|
||
"""Start WeChat sending worker."""
|
||
if self.active:
|
||
return
|
||
|
||
self.active = True
|
||
self.thread = Thread(target=self.run)
|
||
self.thread.start()
|
||
|
||
def stop(self) -> None:
|
||
"""Stop WeChat sending worker."""
|
||
if not self.active:
|
||
return
|
||
|
||
self.active = False
|
||
|
||
if (
|
||
self.thread
|
||
and self.thread.is_alive()
|
||
and self.thread is not current_thread()
|
||
):
|
||
self.thread.join()
|
||
|
||
def send_wechat(self, msg: str) -> None:
|
||
"""Queue a text message for WeChat push."""
|
||
if not self.creds or not self.user_id:
|
||
return
|
||
|
||
self.queue.put(msg)
|
||
|
||
def run(self) -> None:
|
||
"""Send queued WeChat messages in worker thread."""
|
||
while self.active:
|
||
try:
|
||
msg: str = self.queue.get(block=True, timeout=1)
|
||
self.pending_msgs.append(msg)
|
||
except Empty:
|
||
pass
|
||
|
||
while True:
|
||
try:
|
||
self.pending_msgs.append(self.queue.get_nowait())
|
||
except Empty:
|
||
break
|
||
|
||
if not self.pending_msgs:
|
||
continue
|
||
|
||
if not self.creds or not self.user_id:
|
||
continue
|
||
|
||
gap: float = self.send_interval
|
||
if gap > 0:
|
||
prior: float | None = self.last_ts
|
||
if prior is not None:
|
||
wait_secs: float = prior + gap - time.monotonic()
|
||
if wait_secs > 0:
|
||
continue
|
||
|
||
msgs: list[str] = list(self.pending_msgs)
|
||
self.pending_msgs.clear()
|
||
|
||
try:
|
||
self.last_ts = time.monotonic()
|
||
send_text(
|
||
self.creds,
|
||
self.user_id,
|
||
"\n".join(msgs),
|
||
)
|
||
except SessionExpired:
|
||
self.pending_msgs = msgs + self.pending_msgs
|
||
self.active = False
|
||
self.main_engine.write_log(
|
||
_("微信会话已过期,请通过菜单重新扫码绑定"),
|
||
self.engine_name,
|
||
)
|
||
except WeixinError as exc:
|
||
self.pending_msgs = msgs + self.pending_msgs
|
||
self.main_engine.write_log(
|
||
_("微信推送失败:{}").format(exc),
|
||
self.engine_name,
|
||
)
|
||
|
||
def close(self) -> None:
|
||
""""""
|
||
self.deactivate()
|