mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-28 03:01:54 +08:00
⛅ Sync 2026-06-10 21:12:18
This commit is contained in:
parent
15bd7dfdef
commit
2151d80d61
45
deepbot/Makefile
Normal file
45
deepbot/Makefile
Normal file
@ -0,0 +1,45 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:= deepbot
|
||||
PKG_VERSION:= 1.0.0
|
||||
PKG_RELEASE:=1
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/deepbot
|
||||
SECTION:= utils
|
||||
CATEGORY:= Utilities
|
||||
TITLE:= DeepSeek AI Telegram Bot
|
||||
DEPENDS:= +lua +luasocket +lua-cjson +lsqlite3 +curl
|
||||
PKGARCH:= all
|
||||
endef
|
||||
|
||||
define Package/deepbot/description
|
||||
Telegram bot powered by DeepSeek AI.
|
||||
Supports private and group chats, conversation history,
|
||||
daily token limits, SOCKS5/HTTP proxy.
|
||||
Lua implementation from
|
||||
https://github.com/Bimaoitsuki/deepseek-telegram-bot-
|
||||
endef
|
||||
|
||||
define Build/Compile
|
||||
endef
|
||||
|
||||
define Package/deepbot/conffiles
|
||||
/etc/config/deepbot
|
||||
endef
|
||||
|
||||
define Package/deepbot/install
|
||||
$(INSTALL_DIR) $(1)/usr/bin \
|
||||
$(1)/etc/init.d \
|
||||
$(1)/etc/config \
|
||||
$(1)/usr/share/deepbot
|
||||
$(INSTALL_BIN) ./files/usr/bin/deepbot.lua \
|
||||
$(1)/usr/bin/deepbot.lua
|
||||
$(INSTALL_BIN) ./files/etc/init.d/deepbot \
|
||||
$(1)/etc/init.d/deepbot
|
||||
$(INSTALL_DATA) ./files/etc/config/deepbot \
|
||||
$(1)/etc/config/deepbot
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,deepbot))
|
||||
10
deepbot/files/etc/config/deepbot
Normal file
10
deepbot/files/etc/config/deepbot
Normal file
@ -0,0 +1,10 @@
|
||||
config deepbot 'main'
|
||||
option token 'YOUR_BOT_TOKEN' # Telegrab BOT token (from @BotFather)
|
||||
option deepseek_key 'YOUR_DEEPSEEK_KEY' # DeepSeek API key
|
||||
option deepseek_url 'https://api.deepseek.com/v1/chat/completions' # DeepSeek API URL
|
||||
option proxy '' # Proxy server (proxy_type://host:port[@username:passwd] see man curl)
|
||||
option database '/usr/share/deepbot/chat_history.db' # SQLite3 database path
|
||||
option token_limit '10000' # Token linit per day
|
||||
option rate_limit '5' # Max replies per user in one min
|
||||
option group_mention_only '1' # Reply queris in public chats
|
||||
option debug '0' # Verbose log (stdout)
|
||||
40
deepbot/files/etc/init.d/deepbot
Normal file
40
deepbot/files/etc/init.d/deepbot
Normal file
@ -0,0 +1,40 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
START=99
|
||||
STOP=10
|
||||
USE_PROCD=1
|
||||
|
||||
PROG="/usr/bin/deepbot.lua"
|
||||
|
||||
start_service() {
|
||||
local token deepseek_key deepseek_url proxy database \
|
||||
token_limit rate_limit group_mention_only debug
|
||||
|
||||
config_load deepbot
|
||||
config_get token main token ''
|
||||
config_get deepseek_key main deepseek_key ''
|
||||
config_get deepseek_url main deepseek_url 'https://api.deepseek.com/v1/chat/completions'
|
||||
config_get proxy main proxy ''
|
||||
config_get database main database '/usr/share/deepbot/chat_history.db'
|
||||
config_get token_limit main token_limit '10000'
|
||||
config_get rate_limit main rate_limit '5'
|
||||
config_get group_mention_only main group_mention_only '1'
|
||||
config_get debug main debug '0'
|
||||
|
||||
procd_open_instance
|
||||
procd_set_param command lua "$PROG"
|
||||
procd_set_param env \
|
||||
TELEGRAM_BOT_TOKEN="$token" \
|
||||
DEEPSEEK_API_KEY="$deepseek_key" \
|
||||
DEEPSEEK_API_URL="$deepseek_url" \
|
||||
PROXY_URL="$proxy" \
|
||||
DEBUG="$debug"
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn 30 5 0
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "deepbot"
|
||||
}
|
||||
559
deepbot/files/usr/bin/deepbot.lua
Executable file
559
deepbot/files/usr/bin/deepbot.lua
Executable file
@ -0,0 +1,559 @@
|
||||
#!/usr/bin/env lua
|
||||
-- DeepSeek Telegram Bot (Lua 5.1.5)
|
||||
-- Config: /etc/config/deepbot
|
||||
-- Depends: curl lua luasocket lua-cjson lsqlite3 uci
|
||||
|
||||
local socket = require("socket")
|
||||
local json = require("cjson")
|
||||
local sqlite3 = require("lsqlite3")
|
||||
|
||||
-- ===== UCI КОНФИГ =====
|
||||
local function uci_get(key)
|
||||
local f = io.popen("uci -q get deepbot." .. key .. " 2>/dev/null")
|
||||
if not f then return nil end
|
||||
local v = f:read("*l")
|
||||
f:close()
|
||||
return (v and v ~= "") and v or nil
|
||||
end
|
||||
|
||||
local function uci_get_bool(key, default)
|
||||
local v = uci_get(key)
|
||||
if v == nil then return default end
|
||||
return v == "1" or v == "true" or v == "yes"
|
||||
end
|
||||
|
||||
local function uci_get_int(key, default)
|
||||
local v = uci_get(key)
|
||||
return v and tonumber(v) or default
|
||||
end
|
||||
|
||||
-- ===== Config =====
|
||||
-- Prio: envilopment variables > UCI > default value
|
||||
local TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
or uci_get("main.token")
|
||||
or "YOUR_BOT_TOKEN"
|
||||
local DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
|
||||
or uci_get("main.deepseek_key")
|
||||
or "YOUR_DEEPSEEK_KEY"
|
||||
local DEEPSEEK_API_URL = os.getenv("DEEPSEEK_API_URL")
|
||||
or uci_get("main.deepseek_url")
|
||||
or "https://api.deepseek.com/v1/chat/completions"
|
||||
local PROXY_URL = os.getenv("PROXY_URL")
|
||||
or uci_get("main.proxy")
|
||||
or ""
|
||||
local DATABASE_NAME = uci_get("main.database")
|
||||
or "/usr/share/deepbot/chat_history.db"
|
||||
local TOKEN_LIMIT_PER_DAY = uci_get_int("main.token_limit", 10000)
|
||||
local RATE_LIMIT = uci_get_int("main.rate_limit", 5)
|
||||
local DEBUG = os.getenv("DEBUG") == "1"
|
||||
or uci_get_bool("main.debug", false)
|
||||
-- reply in chant only ask or @bot call
|
||||
local GROUP_MENTION_ONLY = uci_get_bool("main.group_mention_only", true)
|
||||
|
||||
local TG_API_BASE = "https://api.telegram.org/bot" .. TELEGRAM_BOT_TOKEN
|
||||
|
||||
-- bot_username defined after getMe
|
||||
local BOT_USERNAME = ""
|
||||
|
||||
-- ===== LOG =====
|
||||
local function log(level, msg)
|
||||
io.stderr:write(string.format("[%s] [%s] %s\n",
|
||||
os.date("%Y-%m-%d %H:%M:%S"), level, tostring(msg)))
|
||||
io.stderr:flush()
|
||||
end
|
||||
local function info(msg) log("INFO", msg) end
|
||||
local function err(msg) log("ERROR", msg) end
|
||||
local function dbg(msg) if DEBUG then log("DEBUG", msg) end end
|
||||
|
||||
-- ===== CURL =====
|
||||
local function curl_post(url, headers, body)
|
||||
local header_args = ""
|
||||
for k, v in pairs(headers) do
|
||||
header_args = header_args .. string.format(" -H '%s: %s'", k, v)
|
||||
end
|
||||
local proxy_arg = (PROXY_URL ~= "") and (" -x '" .. PROXY_URL .. "'") or ""
|
||||
|
||||
local pid_f = io.popen("echo $$")
|
||||
local pid = pid_f and pid_f:read("*l") or "0"
|
||||
if pid_f then pid_f:close() end
|
||||
local tmpfile = string.format("/tmp/deepbot_%s_%s.json", pid, tostring(os.time()))
|
||||
|
||||
local f = io.open(tmpfile, "w")
|
||||
if not f then
|
||||
err("curl_post: cannot write tmpfile " .. tmpfile)
|
||||
return nil
|
||||
end
|
||||
f:write(body)
|
||||
f:flush()
|
||||
f:close()
|
||||
|
||||
local cmd = string.format(
|
||||
"curl -s -m 30 -X POST%s%s --data-binary @'%s' '%s'",
|
||||
header_args, proxy_arg, tmpfile, url
|
||||
)
|
||||
dbg("curl_post CMD: " .. cmd)
|
||||
|
||||
local pipe = io.popen(cmd)
|
||||
if not pipe then
|
||||
os.remove(tmpfile)
|
||||
err("curl_post: popen failed")
|
||||
return nil
|
||||
end
|
||||
local resp = pipe:read("*a")
|
||||
pipe:close()
|
||||
os.remove(tmpfile)
|
||||
|
||||
dbg("curl_post RESP (" .. #resp .. " bytes): " .. resp:sub(1, 300))
|
||||
return resp
|
||||
end
|
||||
|
||||
-- ===== TELEGRAM API =====
|
||||
local function tg_call(method, params)
|
||||
local body = params and json.encode(params) or "{}"
|
||||
dbg("tg_call " .. method .. " body: " .. body:sub(1, 200))
|
||||
local resp = curl_post(
|
||||
TG_API_BASE .. "/" .. method,
|
||||
{ ["Content-Type"] = "application/json" },
|
||||
body
|
||||
)
|
||||
if not resp or resp == "" then
|
||||
err("tg_call " .. method .. ": empty response")
|
||||
return nil
|
||||
end
|
||||
local ok, data = pcall(json.decode, resp)
|
||||
if not ok then
|
||||
err("tg_call " .. method .. " JSON error: " .. tostring(data))
|
||||
err("raw: " .. resp:sub(1, 300))
|
||||
return nil
|
||||
end
|
||||
if not data.ok then
|
||||
err("tg_call " .. method .. " API error: " ..
|
||||
tostring(data.description or json.encode(data)))
|
||||
end
|
||||
return data
|
||||
end
|
||||
|
||||
-- ===== DATABASE =====
|
||||
local db
|
||||
|
||||
local function init_database()
|
||||
os.execute("mkdir -p /usr/share/deepbot")
|
||||
db = sqlite3.open(DATABASE_NAME)
|
||||
local rc = db:exec([[
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
tokens INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_id ON conversations (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON conversations (timestamp);
|
||||
CREATE TABLE IF NOT EXISTS token_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage ON token_usage (user_id, date);
|
||||
]])
|
||||
if rc ~= sqlite3.OK then
|
||||
err("DB init error: " .. tostring(db:errmsg()))
|
||||
else
|
||||
info("Database: " .. DATABASE_NAME)
|
||||
end
|
||||
end
|
||||
|
||||
local function get_user_messages(user_id, limit)
|
||||
limit = limit or 10
|
||||
local messages = {}
|
||||
local stmt = db:prepare(
|
||||
"SELECT role, content FROM conversations WHERE user_id=? ORDER BY timestamp DESC LIMIT ?"
|
||||
)
|
||||
stmt:bind_values(user_id, limit)
|
||||
for row in stmt:nrows() do
|
||||
table.insert(messages, 1, { role = row.role, content = row.content })
|
||||
end
|
||||
stmt:finalize()
|
||||
return messages
|
||||
end
|
||||
|
||||
local function save_message(user_id, role, content, tokens)
|
||||
tokens = tokens or 0
|
||||
local stmt = db:prepare(
|
||||
"INSERT INTO conversations (user_id, role, content, tokens) VALUES (?,?,?,?)"
|
||||
)
|
||||
stmt:bind_values(user_id, role, content, tokens)
|
||||
stmt:step()
|
||||
stmt:finalize()
|
||||
local today = os.date("%Y-%m-%d")
|
||||
db:exec(string.format(
|
||||
"INSERT OR IGNORE INTO token_usage (user_id, date, tokens_used) VALUES (%d,'%s',0)",
|
||||
user_id, today
|
||||
))
|
||||
db:exec(string.format(
|
||||
"UPDATE token_usage SET tokens_used=tokens_used+%d WHERE user_id=%d AND date='%s'",
|
||||
tokens, user_id, today
|
||||
))
|
||||
end
|
||||
|
||||
local function clear_user_conversation(user_id)
|
||||
db:exec(string.format("DELETE FROM conversations WHERE user_id=%d", user_id))
|
||||
end
|
||||
|
||||
local function get_daily_token_usage(user_id)
|
||||
local today = os.date("%Y-%m-%d")
|
||||
local stmt = db:prepare(
|
||||
"SELECT tokens_used FROM token_usage WHERE user_id=? AND date=?"
|
||||
)
|
||||
stmt:bind_values(user_id, today)
|
||||
local result = 0
|
||||
for row in stmt:nrows() do result = row.tokens_used end
|
||||
stmt:finalize()
|
||||
return result
|
||||
end
|
||||
|
||||
-- ===== utils =====
|
||||
local function estimate_tokens(text)
|
||||
return math.max(1, math.floor(#text / 4))
|
||||
end
|
||||
|
||||
local function sanitize_text(text)
|
||||
if #text > 4000 then text = text:sub(1, 4000) .. "…" end
|
||||
return text
|
||||
end
|
||||
|
||||
-- ===== RATE LIMITER =====
|
||||
local rate_table = {}
|
||||
|
||||
local function check_rate(user_id)
|
||||
local now = socket.gettime()
|
||||
local ts = rate_table[user_id] or {}
|
||||
local fresh = {}
|
||||
for _, t in ipairs(ts) do
|
||||
if now - t < 60 then fresh[#fresh+1] = t end
|
||||
end
|
||||
if #fresh >= RATE_LIMIT then
|
||||
rate_table[user_id] = fresh
|
||||
return false
|
||||
end
|
||||
fresh[#fresh+1] = now
|
||||
rate_table[user_id] = fresh
|
||||
return true
|
||||
end
|
||||
|
||||
-- ===== LRU CACHE =====
|
||||
local response_cache = {}
|
||||
local cache_order = {}
|
||||
local CACHE_MAX = 200
|
||||
|
||||
local function cache_get(key) return response_cache[key] end
|
||||
local function cache_set(key, val)
|
||||
if not response_cache[key] then
|
||||
table.insert(cache_order, key)
|
||||
if #cache_order > CACHE_MAX then
|
||||
response_cache[table.remove(cache_order, 1)] = nil
|
||||
end
|
||||
end
|
||||
response_cache[key] = val
|
||||
end
|
||||
|
||||
-- ===== CHAT: reply filter =====
|
||||
-- reply cleared text
|
||||
local function group_filter(msg)
|
||||
local chat_type = msg.chat.type -- "private","group","supergroup","channel"
|
||||
|
||||
-- private msg reply always
|
||||
if chat_type == "private" then
|
||||
return true, msg.text
|
||||
end
|
||||
|
||||
-- in chat — if GROUP_MENTION_ONLY disabled
|
||||
if not GROUP_MENTION_ONLY then
|
||||
return true, msg.text
|
||||
end
|
||||
|
||||
local text = msg.text or ""
|
||||
|
||||
-- 1. Reply
|
||||
if msg.reply_to_message and msg.reply_to_message.from then
|
||||
if msg.reply_to_message.from.username == BOT_USERNAME then
|
||||
dbg("group_filter: reply to bot")
|
||||
return true, text
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. ask bot @BotUsername (any case)
|
||||
if BOT_USERNAME ~= "" then
|
||||
local mention = "@" .. BOT_USERNAME
|
||||
local lower_text = text:lower()
|
||||
local lower_mention = mention:lower()
|
||||
if lower_text:find(lower_mention, 1, true) then
|
||||
-- clean quote
|
||||
local clean = text:gsub("(?i)" .. mention, "")
|
||||
-- lua (?i) not support, make case-insensitive
|
||||
clean = lower_text:gsub(lower_mention, "")
|
||||
clean = clean:match("^%s*(.-)%s*$") -- trim
|
||||
if clean == "" then clean = text end -- if empty — keep
|
||||
dbg("group_filter: mention found, clean text: " .. clean)
|
||||
return true, clean
|
||||
end
|
||||
end
|
||||
|
||||
dbg("group_filter: not addressed to bot, skipping")
|
||||
return false, nil
|
||||
end
|
||||
|
||||
-- ===== DEEPSEEK API =====
|
||||
local function call_deepseek(user_id, prompt)
|
||||
local cache_key = tostring(user_id) .. "\0" .. prompt
|
||||
if cache_get(cache_key) then
|
||||
info("Cache hit user=" .. user_id)
|
||||
return cache_get(cache_key)
|
||||
end
|
||||
|
||||
local daily_usage = get_daily_token_usage(user_id)
|
||||
if daily_usage >= TOKEN_LIMIT_PER_DAY then
|
||||
return nil, "Daily token limit reached"
|
||||
end
|
||||
|
||||
local conversation = get_user_messages(user_id, 10)
|
||||
if #conversation == 0 then
|
||||
local sys = "You are a helpful AI assistant for Telegram users."
|
||||
table.insert(conversation, { role = "system", content = sys })
|
||||
save_message(user_id, "system", sys)
|
||||
end
|
||||
|
||||
local total_est = estimate_tokens(prompt) + 100
|
||||
for _, m in ipairs(conversation) do
|
||||
total_est = total_est + estimate_tokens(m.content)
|
||||
end
|
||||
if daily_usage + total_est > TOKEN_LIMIT_PER_DAY then
|
||||
return nil, "This request would exceed daily token limit"
|
||||
end
|
||||
|
||||
local messages = {}
|
||||
for _, m in ipairs(conversation) do messages[#messages+1] = m end
|
||||
messages[#messages+1] = { role = "user", content = prompt }
|
||||
|
||||
local payload = json.encode({
|
||||
model = "deepseek-chat",
|
||||
messages = messages,
|
||||
temperature = 0.5,
|
||||
})
|
||||
|
||||
local resp_body = curl_post(
|
||||
DEEPSEEK_API_URL,
|
||||
{
|
||||
["Authorization"] = "Bearer " .. DEEPSEEK_API_KEY,
|
||||
["Content-Type"] = "application/json",
|
||||
},
|
||||
payload
|
||||
)
|
||||
|
||||
if not resp_body or resp_body == "" then
|
||||
return nil, "Empty response from DeepSeek API"
|
||||
end
|
||||
|
||||
local ok, data = pcall(json.decode, resp_body)
|
||||
if not ok then
|
||||
err("DeepSeek JSON error: " .. tostring(data))
|
||||
err("Raw: " .. resp_body:sub(1, 300))
|
||||
return nil, "JSON parse error"
|
||||
end
|
||||
|
||||
if data.choices then
|
||||
local ai_reply = data.choices[1].message.content
|
||||
local usage = data.usage or {}
|
||||
local reply_tokens = usage.completion_tokens or estimate_tokens(ai_reply)
|
||||
save_message(user_id, "assistant", ai_reply, reply_tokens)
|
||||
if #prompt < 100 then cache_set(cache_key, data) end
|
||||
info(string.format("DeepSeek OK user=%d tokens=%d", user_id, reply_tokens))
|
||||
return data
|
||||
elseif data.error then
|
||||
local emsg = data.error.message or json.encode(data.error)
|
||||
err("DeepSeek API error: " .. tostring(emsg))
|
||||
return nil, tostring(emsg)
|
||||
else
|
||||
err("DeepSeek unknown response: " .. resp_body:sub(1, 300))
|
||||
return nil, "Unknown API response"
|
||||
end
|
||||
end
|
||||
|
||||
-- ===== Send =====
|
||||
local function send_message(chat_id, text, reply_to)
|
||||
local payload = {
|
||||
chat_id = chat_id,
|
||||
text = sanitize_text(text),
|
||||
parse_mode = "Markdown",
|
||||
disable_web_page_preview = true,
|
||||
}
|
||||
if reply_to then payload.reply_to_message_id = reply_to end
|
||||
local res = tg_call("sendMessage", payload)
|
||||
if not res or not res.ok then
|
||||
-- retry without Markdown
|
||||
payload.parse_mode = nil
|
||||
res = tg_call("sendMessage", payload)
|
||||
end
|
||||
return res
|
||||
end
|
||||
|
||||
-- ===== HANDLERS =====
|
||||
local function handle_start(msg)
|
||||
local user_id = msg.from.id
|
||||
info("handle_start user=" .. user_id)
|
||||
clear_user_conversation(user_id)
|
||||
save_message(user_id, "system", "You are a helpful AI assistant for Telegram users.")
|
||||
send_message(msg.chat.id,
|
||||
"🤖 *DeepSeek AI Bot*\n\n"
|
||||
.. "Conversation history is saved locally.\n"
|
||||
.. "/clear — start a new conversation\n"
|
||||
.. "/history — show recent messages\n"
|
||||
.. "/tokens — daily token usage",
|
||||
msg.message_id
|
||||
)
|
||||
end
|
||||
|
||||
local function handle_clear(msg)
|
||||
info("handle_clear user=" .. msg.from.id)
|
||||
clear_user_conversation(msg.from.id)
|
||||
send_message(msg.chat.id, "🔄 *Conversation reset*", msg.message_id)
|
||||
end
|
||||
|
||||
local function handle_history(msg)
|
||||
local messages = get_user_messages(msg.from.id, 20)
|
||||
if #messages == 0 then
|
||||
send_message(msg.chat.id, "📜 *Conversation history is empty*", msg.message_id)
|
||||
return
|
||||
end
|
||||
local lines = { "📜 *Recent Conversation:*\n" }
|
||||
local start = math.max(1, #messages - 4)
|
||||
for i = start, #messages do
|
||||
local m = messages[i]
|
||||
local role = (m.role == "user") and "You" or "AI"
|
||||
local text = m.content:sub(1, 200)
|
||||
if #m.content > 200 then text = text .. "…" end
|
||||
lines[#lines+1] = string.format("*%s:* %s\n", role, text)
|
||||
end
|
||||
send_message(msg.chat.id, table.concat(lines, "\n"), msg.message_id)
|
||||
end
|
||||
|
||||
local function handle_tokens(msg)
|
||||
local user_id = msg.from.id
|
||||
local daily_usage = get_daily_token_usage(user_id)
|
||||
local pct = string.format("%.1f", daily_usage / TOKEN_LIMIT_PER_DAY * 100)
|
||||
send_message(msg.chat.id,
|
||||
string.format(
|
||||
"🧮 *Daily Token Usage:*\n\n"
|
||||
.. "• Used today: %d\n"
|
||||
.. "• Daily limit: %d\n"
|
||||
.. "• Percentage: %s%%\n\n"
|
||||
.. "Stats reset at midnight UTC.",
|
||||
daily_usage, TOKEN_LIMIT_PER_DAY, pct
|
||||
),
|
||||
msg.message_id
|
||||
)
|
||||
end
|
||||
|
||||
local function handle_text(msg, clean_text)
|
||||
local user_id = msg.from.id
|
||||
local chat_id = msg.chat.id
|
||||
local text = clean_text or msg.text
|
||||
info(string.format("handle_text user=%d chat=%d text=%q",
|
||||
user_id, chat_id, text:sub(1, 80)))
|
||||
|
||||
if not check_rate(user_id) then
|
||||
send_message(chat_id, "⏳ *Too many requests!* Please wait 1 minute.", msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
tg_call("sendChatAction", { chat_id = chat_id, action = "typing" })
|
||||
|
||||
local data, api_err = call_deepseek(user_id, text)
|
||||
if api_err then
|
||||
err("DeepSeek error user=" .. user_id .. ": " .. api_err)
|
||||
send_message(chat_id, "❌ *Error:* " .. api_err, msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
local answer = data.choices[1].message.content
|
||||
send_message(chat_id, answer, msg.message_id)
|
||||
end
|
||||
|
||||
-- ===== manager =====
|
||||
local function dispatch(msg)
|
||||
if not msg then dbg("dispatch: nil message") return end
|
||||
if not msg.text then dbg("dispatch: no text") return end
|
||||
dbg(string.format("dispatch: chat_type=%s text=%q",
|
||||
tostring(msg.chat and msg.chat.type), msg.text:sub(1, 80)))
|
||||
|
||||
local text = msg.text
|
||||
|
||||
-- commands run any
|
||||
if text == "/start" or text:find("^/start@") then handle_start(msg)
|
||||
elseif text == "/clear" or text:find("^/clear@") then handle_clear(msg)
|
||||
elseif text == "/history" or text:find("^/history@") then handle_history(msg)
|
||||
elseif text == "/tokens" or text:find("^/tokens@") then handle_tokens(msg)
|
||||
elseif text:sub(1,1) ~= "/" then
|
||||
-- for standart msg — check public filter
|
||||
local should_reply, clean_text = group_filter(msg)
|
||||
if should_reply then
|
||||
handle_text(msg, clean_text)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ===== LONG POLLING =====
|
||||
local function run()
|
||||
info("Lua version: " .. _VERSION)
|
||||
info("Token configured: " .. (TELEGRAM_BOT_TOKEN ~= "YOUR_BOT_TOKEN" and "YES" or "NO"))
|
||||
info("DeepSeek key configured: " .. (DEEPSEEK_API_KEY ~= "YOUR_DEEPSEEK_KEY" and "YES" or "NO"))
|
||||
info("Debug mode: " .. (DEBUG and "ON" or "OFF"))
|
||||
info("Proxy: " .. (PROXY_URL ~= "" and PROXY_URL or "none"))
|
||||
info("Group mention only: " .. (GROUP_MENTION_ONLY and "ON" or "OFF"))
|
||||
|
||||
local test = io.popen("curl --version 2>&1 | head -1")
|
||||
if test then info("curl: " .. (test:read("*l") or "?")); test:close() end
|
||||
|
||||
info("Checking token via getMe...")
|
||||
local me = tg_call("getMe", {})
|
||||
if me and me.ok then
|
||||
BOT_USERNAME = me.result.username or ""
|
||||
info("Bot: @" .. BOT_USERNAME)
|
||||
else
|
||||
err("getMe failed — проверь TELEGRAM_BOT_TOKEN!")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
init_database()
|
||||
info("Bot started. Long polling...")
|
||||
|
||||
local offset = 0
|
||||
while true do
|
||||
dbg("getUpdates offset=" .. offset)
|
||||
local res = tg_call("getUpdates", {
|
||||
offset = offset,
|
||||
timeout = 30,
|
||||
allowed_updates = { "message" },
|
||||
})
|
||||
|
||||
if res and res.ok and res.result then
|
||||
local count = #res.result
|
||||
if count > 0 then info("getUpdates: " .. count .. " update(s)") end
|
||||
for _, update in ipairs(res.result) do
|
||||
offset = update.update_id + 1
|
||||
dbg("update_id=" .. update.update_id)
|
||||
local ok, e = pcall(dispatch, update.message)
|
||||
if not ok then err("dispatch error: " .. tostring(e)) end
|
||||
end
|
||||
else
|
||||
err("getUpdates failed, sleeping 5s...")
|
||||
if res then err("Response: " .. json.encode(res)) end
|
||||
socket.sleep(5)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
run()
|
||||
@ -118,7 +118,7 @@ const glossary = {
|
||||
prefmt: '%s_nodedomain',
|
||||
field: 'proxy-server-nameserver-policy',
|
||||
},
|
||||
node: {
|
||||
node: { // outbound
|
||||
prefmt: 'node_%s',
|
||||
field: 'proxies',
|
||||
},
|
||||
@ -207,13 +207,21 @@ const outbound_type = [
|
||||
|
||||
const preset_outbound = {
|
||||
full: [
|
||||
['DIRECT'], // built-in Outbound
|
||||
['REJECT'], // built-in Outbound
|
||||
['REJECT-DROP'], // built-in Outbound
|
||||
['PASS'], // built-in Outbound
|
||||
['PASS-RULE'], // built-in Outbound
|
||||
['COMPATIBLE'], // built-in Outbound
|
||||
['GLOBAL'] // built-in Proxy Group
|
||||
],
|
||||
proxy: [ // built-in Outbound
|
||||
['DIRECT'],
|
||||
['REJECT'],
|
||||
['REJECT-DROP'],
|
||||
['PASS'],
|
||||
['PASS-RULE'],
|
||||
['COMPATIBLE'],
|
||||
['GLOBAL']
|
||||
['COMPATIBLE']
|
||||
],
|
||||
direct: [
|
||||
['', _('null')],
|
||||
|
||||
@ -295,6 +295,7 @@ function renderListeners(s, uciconfig, isClient) {
|
||||
]
|
||||
o = s.taboption('field_general', hm.GenValue, 'sudoku_key', _('Key'),
|
||||
_('The ED25519 master public key or UUID generated by Sudoku.'));
|
||||
o.password = true;
|
||||
o.hm_options = {
|
||||
type: sudoku_keytypes[0][0],
|
||||
callback: function(result) {
|
||||
|
||||
@ -13,7 +13,7 @@ const parseProxyGroupYaml = hm.parseYaml.extend({
|
||||
if (!cfg.type)
|
||||
return null;
|
||||
|
||||
// key mapping // 2026/06/06
|
||||
// key mapping // 2026/06/10
|
||||
let config = hm.removeBlankAttrs({
|
||||
id: this.id,
|
||||
label: this.label,
|
||||
@ -1083,12 +1083,12 @@ return view.extend({
|
||||
so.default = so.disabled;
|
||||
so.editable = true;
|
||||
|
||||
so = ss.taboption('field_general', form.ListValue, 'empty_fallback', _('Empty fallback'));
|
||||
so.default = 'COMPATIBLE';
|
||||
hm.preset_outbound.full.forEach((res) => {
|
||||
so = ss.taboption('field_general', form.Value, 'empty_fallback', _('Empty fallback'));
|
||||
so.default = ''; // COMPATIBLE
|
||||
hm.preset_outbound.proxy.forEach((res) => {
|
||||
so.value.apply(so, res);
|
||||
})
|
||||
so.load = L.bind(hm.loadProxyGroupLabel, so, hm.preset_outbound.full);
|
||||
so.load = L.bind(hm.loadNodeLabel, so, hm.preset_outbound.proxy);
|
||||
so.modalonly = true;
|
||||
|
||||
/* Override fields */
|
||||
|
||||
@ -385,6 +385,24 @@ return view.extend({
|
||||
|
||||
so = ss.option(form.DummyValue, '_china_list_version', _('China list version'));
|
||||
so.cfgvalue = function() { return renderResVersion.call(this, null, 'china_list') };
|
||||
|
||||
so = ss.option(form.Value, 'github_token', _('GitHub token'));
|
||||
so.password = true;
|
||||
so.renderWidget = function(/* ... */) {
|
||||
let node = form.Value.prototype.renderWidget.apply(this, arguments);
|
||||
|
||||
(node.querySelector('.control-group') || node).appendChild(E('button', {
|
||||
class: 'cbi-button cbi-button-apply',
|
||||
title: _('Save'),
|
||||
click: ui.createHandlerFn(this, () => {
|
||||
return this.map.save(null, true).then(() => {
|
||||
ui.changes.apply(true);
|
||||
});
|
||||
}, this.option)
|
||||
}, [ _('Save') ]));
|
||||
|
||||
return node;
|
||||
}
|
||||
/* Overview END */
|
||||
|
||||
/* General START */
|
||||
|
||||
@ -14,6 +14,16 @@ document.querySelector('head').appendChild(E('link', {
|
||||
'href': L.resource('view/fchomo/node.css')
|
||||
}));
|
||||
|
||||
const age_encryption = {
|
||||
keypairs: {
|
||||
types: [
|
||||
['age-x25519', _('age-x25519')],
|
||||
['age-mlkem768-x25519', _('age-mlkem768-x25519')],
|
||||
['age-convert', _('Derive from priv-key')]
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const CBIBubblesValue = form.DummyValue.extend({
|
||||
__name__: 'CBI.BubblesValue',
|
||||
|
||||
@ -94,7 +104,7 @@ const parseProviderYaml = hm.parseYaml.extend({
|
||||
if (!cfg.type)
|
||||
return null;
|
||||
|
||||
// key mapping // 2026/01/17
|
||||
// key mapping // 2026/06/06
|
||||
let config = hm.removeBlankAttrs({
|
||||
id: this.id,
|
||||
label: this.label,
|
||||
@ -107,6 +117,7 @@ const parseProviderYaml = hm.parseYaml.extend({
|
||||
size_limit: cfg["size-limit"],
|
||||
interval: cfg.interval,
|
||||
proxy: cfg.proxy ? hm.preset_outbound.full.map(([key, label]) => key).includes(cfg.proxy) ? cfg.proxy : this.calcID(hm.glossary["proxy_group"].field, cfg.proxy) : null,
|
||||
age_private_key: cfg["age-secret-key"],
|
||||
header: cfg.header ? JSON.stringify(cfg.header, null, 2) : null, // string: object
|
||||
/* Health fields */
|
||||
health_enable: this.bool2str(this.jq(cfg, "health-check.enable")), // bool
|
||||
@ -1519,6 +1530,7 @@ return view.extend({
|
||||
' interval: 3600\n' +
|
||||
' proxy: DIRECT\n' +
|
||||
' size-limit: 0\n' +
|
||||
' age-secret-key: AGE-SECRET-KEY-1ZTQLLN0A4U3ZTT3DCZKYN0CGZEZQLWX2DFTXUWMT4ZHR0N2UG6LSW9NT0N\n' +
|
||||
' header:\n' +
|
||||
' User-Agent:\n' +
|
||||
' - "mihomo/1.18.3"\n' +
|
||||
@ -1526,6 +1538,8 @@ return view.extend({
|
||||
" - 'application/vnd.github.v3.raw'\n" +
|
||||
' Authorization:\n' +
|
||||
" - 'token 1231231'\n" +
|
||||
' X-Age-Public-Key:\n' +
|
||||
" - 'age1xh86kh9v23vattr58yedspm3f57sxvnswu9krr6ns438amekx5gsd09uma'\n" +
|
||||
' health-check:\n' +
|
||||
' enable: true\n' +
|
||||
' interval: 600\n' +
|
||||
@ -1701,9 +1715,74 @@ return view.extend({
|
||||
//so.editable = true;
|
||||
so.depends('type', 'http');
|
||||
|
||||
so = ss.taboption('field_general', hm.GenValue, 'age_private_key', _('age private key'));
|
||||
so.password = true;
|
||||
so.hm_options = {
|
||||
type: age_encryption.keypairs.types[0][0],
|
||||
params: '',
|
||||
callback: function(result) {
|
||||
const section_id = this.section.section;
|
||||
|
||||
let header = {};
|
||||
try {
|
||||
header = JSON.parse(this.section.formvalue(section_id, 'header').trim());
|
||||
} catch {}
|
||||
|
||||
header['X-Age-Public-Key'] = [result.public_key].filter(Boolean);
|
||||
|
||||
return [
|
||||
[this.option, this.hm_options.params || result.private_key],
|
||||
['age_public_key', result.public_key],
|
||||
['header', JSON.stringify(header, null, 2)]
|
||||
]
|
||||
}
|
||||
}
|
||||
so.renderWidget = function(section_id, option_index, cfgvalue) {
|
||||
let node = form.Value.prototype.renderWidget.call(this, section_id, option_index, cfgvalue);
|
||||
const cbid = this.cbid(section_id) + '._keytype_select';
|
||||
const selected = this.hm_options.type;
|
||||
|
||||
let selectEl = E('select', {
|
||||
id: cbid,
|
||||
class: 'cbi-input-select',
|
||||
style: 'width: 10em',
|
||||
});
|
||||
|
||||
age_encryption.keypairs.types.forEach(([k, v]) => {
|
||||
selectEl.appendChild(E('option', {
|
||||
'value': k,
|
||||
'selected': (k === selected) ? '' : null
|
||||
}, [ v ]));
|
||||
});
|
||||
|
||||
node.appendChild(E('div', { 'class': 'control-group' }, [
|
||||
selectEl,
|
||||
E('button', {
|
||||
class: 'cbi-button cbi-button-add',
|
||||
click: ui.createHandlerFn(this, () => {
|
||||
this.hm_options.type = document.getElementById(cbid).value;
|
||||
if (this.hm_options.type === 'age-convert')
|
||||
this.hm_options.params = this.formvalue(section_id);
|
||||
else
|
||||
this.hm_options.params = '';
|
||||
|
||||
return hm.handleGenKey.call(this, this.hm_options);
|
||||
})
|
||||
}, [ _('Generate') ])
|
||||
]));
|
||||
|
||||
return node;
|
||||
}
|
||||
so.depends('type', 'http');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_general', hm.CopyValue, 'age_public_key', _('age public key'));
|
||||
so.depends('type', 'http');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_general', hm.TextValue, 'header', _('HTTP header'),
|
||||
_('Custom HTTP header.'));
|
||||
so.placeholder = '{\n "User-Agent": [\n "mihomo/1.18.3"\n ],\n "Accept": [\n //"application/vnd.github.v3.raw"\n ],\n "Authorization": [\n //"token 1231231"\n ]\n}';
|
||||
so.placeholder = '{\n "User-Agent": [\n "mihomo/1.18.3"\n ],\n "Accept": [\n //"application/vnd.github.v3.raw"\n ],\n "Authorization": [\n //"token 1231231"\n ]\n "X-Age-Public-Key": [\n //"age1xh86kh9v23vattr58yedspm3f57sxvnswu9krr6ns438amekx5gsd09uma"\n ]\n}';
|
||||
so.validate = hm.validateJson;
|
||||
so.depends('type', 'http');
|
||||
so.modalonly = true;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@
|
||||
. /usr/share/libubox/jshn.sh
|
||||
|
||||
CONF="fchomo"
|
||||
GITHUB_TOKEN="$(uci -q get $CONF.resources.github_token)"
|
||||
|
||||
RESOURCES_DIR="/etc/$CONF/resources"
|
||||
VER_PATH="/etc/$CONF/resources.json"
|
||||
@ -40,6 +41,7 @@ check_dashboard_update() {
|
||||
local dashrepoid="$(echo -n "$dashrepo" | sed 's|\W|_|g' | tr 'A-Z' 'a-z')"
|
||||
local lock="$RUN_DIR/update_resources-$dashtype.lock"
|
||||
local wget="wget --tries=1 --timeout=10 -q"
|
||||
[ -z "$GITHUB_TOKEN" ] || local github_token="--header=Authorization: Bearer $GITHUB_TOKEN"
|
||||
|
||||
exec 200>"$lock"
|
||||
if ! flock -n 200 &> "/dev/null"; then
|
||||
@ -47,9 +49,9 @@ check_dashboard_update() {
|
||||
return 2
|
||||
fi
|
||||
|
||||
local dash_ver="$($wget -O- "https://api.github.com/repos/$dashrepo/releases/latest" | jsonfilter -e "@.tag_name" 2>/dev/null)"
|
||||
local dash_ver="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$dashrepo/releases/latest" | jsonfilter -qe "@.tag_name" 2>/dev/null)"
|
||||
[ -n "$dash_ver" ] || {
|
||||
dash_ver="$($wget -O- "https://api.github.com/repos/$dashrepo/tags" | jsonfilter -e "@[*].name" | head -n1)"
|
||||
dash_ver="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$dashrepo/tags" | jsonfilter -qe "@[*].name" | head -n1)"
|
||||
}
|
||||
if [ -z "$dash_ver" ]; then
|
||||
log "[$(to_upper "$dashtype")] [$dashrepo] Failed to get the latest version, please retry later."
|
||||
@ -65,7 +67,7 @@ check_dashboard_update() {
|
||||
log "[$(to_upper "$dashtype")] [$dashrepo] Local version: $local_dash_ver, latest version: $dash_ver."
|
||||
fi
|
||||
|
||||
if ! $wget "https://codeload.github.com/$dashrepo/tar.gz/refs/heads/gh-pages" -O "$RUN_DIR/$dashtype.tgz" || ! tar -tzf "$RUN_DIR/$dashtype.tgz" >/dev/null; then
|
||||
if ! $wget "${github_token:--q}" "https://codeload.github.com/$dashrepo/tar.gz/refs/heads/gh-pages" -O "$RUN_DIR/$dashtype.tgz" || ! tar -tzf "$RUN_DIR/$dashtype.tgz" >/dev/null; then
|
||||
rm -f "$RUN_DIR/$dashtype.tgz"
|
||||
log "[$(to_upper "$dashtype")] [$dashrepo] Update failed."
|
||||
return 1
|
||||
@ -90,6 +92,7 @@ check_geodata_update() {
|
||||
local georepo="$2"
|
||||
local lock="$RUN_DIR/update_resources-$geotype.lock"
|
||||
local wget="wget --tries=1 --timeout=10 -q"
|
||||
[ -z "$GITHUB_TOKEN" ] || local github_token="--header=Authorization: Bearer $GITHUB_TOKEN"
|
||||
|
||||
exec 200>"$lock"
|
||||
if ! flock -n 200 &> "/dev/null"; then
|
||||
@ -97,7 +100,7 @@ check_geodata_update() {
|
||||
return 2
|
||||
fi
|
||||
|
||||
local geodata_ver="$($wget -O- "https://api.github.com/repos/$georepo/releases/latest" | jsonfilter -e "@.tag_name")"
|
||||
local geodata_ver="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$georepo/releases/latest" | jsonfilter -qe "@.tag_name")"
|
||||
if [ -z "$geodata_ver" ]; then
|
||||
log "[$(to_upper "$geotype")] Failed to get the latest version, please retry later."
|
||||
return 1
|
||||
@ -113,8 +116,8 @@ check_geodata_update() {
|
||||
fi
|
||||
|
||||
local geodata_hash
|
||||
$wget "https://github.com/$georepo/releases/download/$geodata_ver/$geotype.dat" -O "$RUN_DIR/$geotype.dat"
|
||||
geodata_hash="$($wget -O- "https://github.com/$georepo/releases/download/$geodata_ver/$geotype.dat.sha256sum" | awk '{print $1}')"
|
||||
$wget "${github_token:--q}" "https://github.com/$georepo/releases/download/$geodata_ver/$geotype.dat" -O "$RUN_DIR/$geotype.dat"
|
||||
geodata_hash="$($wget "${github_token:--q}" -O- "https://github.com/$georepo/releases/download/$geodata_ver/$geotype.dat.sha256sum" | awk '{print $1}')"
|
||||
if ! echo -e "$geodata_hash $RUN_DIR/$geotype.dat" | sha256sum -s -c -; then
|
||||
rm -f "$RUN_DIR/$geotype.dat"
|
||||
log "[$(to_upper "$geotype")] Update failed."
|
||||
@ -139,6 +142,7 @@ check_list_update() {
|
||||
local listname="$4"
|
||||
local lock="$RUN_DIR/update_resources-$listtype.lock"
|
||||
local wget="wget --tries=1 --timeout=10 -q"
|
||||
[ -z "$GITHUB_TOKEN" ] || local github_token="--header=Authorization: Bearer $GITHUB_TOKEN"
|
||||
|
||||
exec 200>"$lock"
|
||||
if ! flock -n 200 &> "/dev/null"; then
|
||||
@ -146,9 +150,9 @@ check_list_update() {
|
||||
return 2
|
||||
fi
|
||||
|
||||
local list_info="$($wget -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname")"
|
||||
local list_sha="$(echo -e "$list_info" | jsonfilter -e "@[0].sha")"
|
||||
local list_ver="$(echo -e "$list_info" | jsonfilter -e "@[0].commit.message" | grep -Eo "[0-9-]+" | tr -d ' \n-')"
|
||||
local list_info="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname")"
|
||||
local list_sha="$(echo -e "$list_info" | jsonfilter -qe "@[0].sha")"
|
||||
local list_ver="$(echo -e "$list_info" | jsonfilter -qe "@[0].commit.message" | grep -Eo "[0-9-]+" | tr -d ' \n-')"
|
||||
if [ -z "$list_sha" ] || [ -z "$list_ver" ]; then
|
||||
log "[$(to_upper "$listtype")] Failed to get the latest version, please retry later."
|
||||
return 1
|
||||
|
||||
@ -121,18 +121,7 @@ function parse_filter(cfg) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function get_proxynode(cfg) {
|
||||
if (isEmpty(cfg))
|
||||
return null;
|
||||
|
||||
const label = uci.get(uciconf, cfg, 'label');
|
||||
if (isEmpty(label))
|
||||
die(sprintf("%s's label is missing, please check your configuration.", cfg));
|
||||
else
|
||||
return label;
|
||||
}
|
||||
|
||||
function get_proxygroup(cfg) {
|
||||
function get_proxy(cfg) {
|
||||
if (isEmpty(cfg))
|
||||
return null;
|
||||
|
||||
@ -164,7 +153,7 @@ function get_nameserver(cfg, detour) {
|
||||
});
|
||||
} else
|
||||
push(servers, replace(dnsservers[k]?.address || '', /#detour=([^&]+)/, (m, c1) => {
|
||||
return '#' + urlencode(get_proxygroup(detour || c1));
|
||||
return '#' + urlencode(get_proxy(detour || c1));
|
||||
}));
|
||||
}
|
||||
|
||||
@ -177,7 +166,7 @@ function parse_entry(cfg) {
|
||||
|
||||
let rule = json(cfg);
|
||||
if (rule.detour)
|
||||
rule.detour = get_proxygroup(rule.detour);
|
||||
rule.detour = get_proxy(rule.detour);
|
||||
|
||||
function _payloadStrategy(payload) {
|
||||
// LOGIC_TYPE,((payload1),(payload2))
|
||||
@ -225,7 +214,7 @@ uci.foreach(uciconf, ucichain, (cfg) => {
|
||||
return;
|
||||
|
||||
dialerproxy[identifier] = {
|
||||
detour: get_proxygroup(cfg.chain_tail_group) || get_proxynode(cfg.chain_tail)
|
||||
detour: get_proxy(cfg.chain_tail_group) || get_proxy(cfg.chain_tail)
|
||||
};
|
||||
});
|
||||
|
||||
@ -349,7 +338,7 @@ uci.foreach(uciconf, uciinbd, (cfg) => {
|
||||
if (cfg.enabled === '0')
|
||||
return;
|
||||
|
||||
push(config.listeners, parseListener(cfg, true, get_proxygroup(cfg.proxy)));
|
||||
push(config.listeners, parseListener(cfg, true, get_proxy(cfg.proxy)));
|
||||
});
|
||||
/* Tun settings */
|
||||
if (match(proxy_mode, /tun/))
|
||||
@ -738,14 +727,14 @@ uci.foreach(uciconf, ucipgrp, (cfg) => {
|
||||
name: cfg.label,
|
||||
type: cfg.type,
|
||||
proxies: [
|
||||
...map(cfg.groups || [], cfg => get_proxygroup(cfg)),
|
||||
...map(cfg.proxies || [], cfg => get_proxynode(cfg))
|
||||
...map(cfg.groups || [], cfg => get_proxy(cfg)),
|
||||
...map(cfg.proxies || [], cfg => get_proxy(cfg))
|
||||
],
|
||||
use: cfg.use,
|
||||
"include-all": strToBool(cfg.include_all),
|
||||
"include-all-proxies": strToBool(cfg.include_all_proxies),
|
||||
"include-all-providers": strToBool(cfg.include_all_providers),
|
||||
"empty-fallback": cfg.empty_fallback ? get_proxygroup(cfg.empty_fallback) : null,
|
||||
"empty-fallback": cfg.empty_fallback ? get_proxy(cfg.empty_fallback) : null,
|
||||
// Url-test fields
|
||||
tolerance: (cfg.type === 'url-test') ? strToInt(cfg.tolerance) ?? 150 : null,
|
||||
// Load-balance fields
|
||||
@ -786,7 +775,8 @@ uci.foreach(uciconf, uciprov, (cfg) => {
|
||||
url: cfg.url,
|
||||
"size-limit": bytesizeToByte(cfg.size_limit) || null,
|
||||
interval: (cfg.type === 'http') ? durationToSecond(cfg.interval) ?? 86400 : null,
|
||||
proxy: get_proxygroup(cfg.proxy),
|
||||
proxy: get_proxy(cfg.proxy),
|
||||
"age-secret-key": cfg.age_private_key,
|
||||
header: cfg.header ? json(cfg.header) : null,
|
||||
/* Health fields */
|
||||
"health-check": cfg.health_enable === '0' ? {enable: false} : {
|
||||
@ -843,7 +833,7 @@ uci.foreach(uciconf, ucirule, (cfg) => {
|
||||
"path-in-bundle": cfg.path_in_bundle,
|
||||
"size-limit": bytesizeToByte(cfg.size_limit) || null,
|
||||
interval: (cfg.type === 'http') ? durationToSecond(cfg.interval) ?? 259200 : null,
|
||||
proxy: get_proxygroup(cfg.proxy),
|
||||
proxy: get_proxy(cfg.proxy),
|
||||
header: cfg.header ? json(cfg.header) : null
|
||||
})
|
||||
};
|
||||
|
||||
@ -42,8 +42,8 @@
|
||||
"path": "fchomo/inbound"
|
||||
}
|
||||
},
|
||||
"admin/services/fchomo/node": {
|
||||
"title": "Node",
|
||||
"admin/services/fchomo/outbound": {
|
||||
"title": "Outbound",
|
||||
"order": 30,
|
||||
"action": {
|
||||
"type": "view",
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import { access, lsdir, lstat, popen, readfile, writefile } from 'fs';
|
||||
import { access, lsdir, lstat, mkstemp, popen, readfile, writefile } from 'fs';
|
||||
import {
|
||||
shellQuote, isBinary, yqRead, yqReadFile,
|
||||
shellQuote, isBinary, executeCommand, yqRead, yqReadFile,
|
||||
HM_DIR, EXE_DIR, SDL_DIR, RUN_DIR
|
||||
} from 'fchomo';
|
||||
|
||||
@ -44,13 +44,26 @@ const methods = {
|
||||
args: { type: 'type', params: 'params' },
|
||||
call: function(req) {
|
||||
/* https://github.com/MetaCubeX/mihomo/blob/Alpha/component/generator/cmd.go */
|
||||
if (!(req.args?.type in ['uuid', 'reality-keypair', 'wg-keypair', 'ech-keypair', 'vless-mlkem768', 'vless-x25519', 'sudoku-keypair']))
|
||||
if (!(req.args?.type in ['uuid',
|
||||
'reality-keypair', 'wg-keypair', 'sudoku-keypair',
|
||||
'age-x25519', 'age-mlkem768-x25519', 'age-convert',
|
||||
'vless-mlkem768', 'vless-x25519',
|
||||
'ech-keypair'])) {
|
||||
return { result: false, error: 'illegal type' };
|
||||
}
|
||||
|
||||
const type = req.args?.type;
|
||||
let cmd = `generate ${type}`;
|
||||
let result = {};
|
||||
|
||||
const fd = popen('/usr/bin/mihomo generate ' + type + (req.args?.params ? ' ' + shellQuote(req.args.params) : ''));
|
||||
if (type === 'age-x25519') {
|
||||
cmd = 'age keygen';
|
||||
} else if (type === 'age-mlkem768-x25519') {
|
||||
cmd = 'age keygen-pq';
|
||||
} else if (type === 'age-convert') {
|
||||
cmd = 'age convert';
|
||||
}
|
||||
const fd = popen(`/usr/bin/mihomo ${cmd}` + (req.args?.params ? ' ' + shellQuote(req.args.params) : ''));
|
||||
if (fd) {
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
|
||||
if (type === 'uuid')
|
||||
@ -62,6 +75,15 @@ const methods = {
|
||||
let pub = match(trim(line), /PublicKey: (.*)/);
|
||||
if (pub)
|
||||
result.public_key = pub[1];
|
||||
} else if (type in ['age-x25519', 'age-mlkem768-x25519']) {
|
||||
let priv = match(trim(line), /AGE-SECRET-KEY-.*/);
|
||||
if (priv)
|
||||
result.private_key = priv[0];
|
||||
let pub = match(trim(line), /# public key: (.*)/);
|
||||
if (pub)
|
||||
result.public_key = pub[1];
|
||||
} else if (type in ['age-convert']) {
|
||||
result.public_key = trim(line);
|
||||
} else if (type in ['vless-x25519', 'vless-mlkem768']) {
|
||||
let priv = match(trim(line), /PrivateKey: (.*)/);
|
||||
if (priv)
|
||||
@ -96,6 +118,56 @@ const methods = {
|
||||
}
|
||||
},
|
||||
|
||||
age_crypto: {
|
||||
args: { action: 'action', key: 'key', content: 'content'},
|
||||
call: function(req) {
|
||||
if (!(req.args?.action in ['decrypt', 'encrypt']))
|
||||
return { result: false, error: 'illegal action' };
|
||||
|
||||
if (!req.args?.key)
|
||||
return { result: false, error: 'illegal key' };
|
||||
|
||||
if (!req.args?.content)
|
||||
return { result: '' };
|
||||
|
||||
let content = req.args?.content;
|
||||
let infd = mkstemp();
|
||||
|
||||
if (content) {
|
||||
content = trim(content);
|
||||
content = replace(content, /\r\n?/g, '\n');
|
||||
}
|
||||
infd.write(content);
|
||||
|
||||
infd.seek();
|
||||
const out = executeCommand(infd, '/usr/bin/mihomo age', req.args?.action, req.args?.key, '-', '-');
|
||||
infd.close();
|
||||
|
||||
return out.exitcode === 0 ? { result: out.stdout } : { result: false, error: out.stderr };
|
||||
}
|
||||
},
|
||||
|
||||
age_crypto_file: {
|
||||
args: { action: 'action', key: 'key', input: 'input', output: 'output'},
|
||||
call: function(req) {
|
||||
if (!(req.args?.action in ['decrypt', 'encrypt']))
|
||||
return { result: false, error: 'illegal action' };
|
||||
|
||||
if (!req.args?.key)
|
||||
return { result: false, error: 'illegal key' };
|
||||
|
||||
if ((!req.args?.input) || match(req.args?.input, /\.\.\//))
|
||||
return { result: false, error: 'illegal input' };
|
||||
|
||||
if ((!req.args?.output) || match(req.args?.output, /\.\.\//))
|
||||
return { result: false, error: 'illegal output' };
|
||||
|
||||
const out = executeCommand(null, '/usr/bin/mihomo age', req.args?.action, req.args?.key, req.args?.input, req.args?.output);
|
||||
|
||||
return out.exitcode === 0 ? { result: true } : { result: false, error: out.stderr };
|
||||
}
|
||||
},
|
||||
|
||||
get_features: {
|
||||
call: function() {
|
||||
let features = {};
|
||||
|
||||
@ -62,7 +62,7 @@ The Vite development server uses middleware to rewrite local requests to serve C
|
||||
|
||||
1. Proxies `/cgi-bin` and `/luci-static` requests to OpenWrt device
|
||||
2. Uses middleware (`createLocalServePlugin`) to rewrite request paths for CSS and JS files
|
||||
3. CSS requests to `/luci-static/aurora/main.css` are rewritten to serve from `.dev/src/media/main.css`
|
||||
3. CSS requests to `/luci-static/aurora/main.css` and `/luci-static/aurora/login.css` are rewritten to serve from `.dev/src/media/main.css` and `.dev/src/media/login.css` respectively
|
||||
4. JS file requests are served directly from `.dev/src/resource/` with middleware reading and returning file content
|
||||
5. Injects Vite HMR client into proxied HTML responses for live reload support
|
||||
6. Redirects `/` to `/cgi-bin/luci` for proper routing
|
||||
@ -83,6 +83,20 @@ Thanks to **lightningcss**, you can freely use [CSS Nesting syntax](https://draf
|
||||
|
||||
This will be compiled to standard CSS that works in all browsers.
|
||||
|
||||
### CSS Architecture
|
||||
|
||||
The theme has two independent Tailwind CSS v4 entry points, both sourced from `.dev/src/media/`:
|
||||
|
||||
- **`main.css`** — the LuCI admin UI. It contains no rules of its own; it's a pure import manifest that pulls in (in order) `_tokens.css` (OKLCH theme tokens, mapped via `@theme inline`), `_base.css`, `_layout.css`, every file in `components/` (one partial per UI component — buttons, cards, modals, tables, etc.), `_utilities.css`, and `_patches.css`.
|
||||
- **`login.css`** — the standalone login page (`sysauth.ut`). Self-contained: imports Tailwind and `_tokens.css` directly.
|
||||
|
||||
**Adding new styles:**
|
||||
|
||||
- New UI component → create `components/_<name>.css` and add an `@import` line to `main.css`. Each file is its own organizational unit — no `@layer` wrappers needed (any that remain are stripped by PostCSS).
|
||||
- Compatibility fix for a third-party LuCI app/page → add a narrow, selector-scoped rule to `_patches.css` under a comment naming the app (e.g. `/* luci-app-openclash */`).
|
||||
|
||||
All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS properties.
|
||||
|
||||
### LuCI JavaScript API
|
||||
|
||||
For LuCI-specific JavaScript development, refer to the official API documentation:
|
||||
@ -150,7 +164,8 @@ This compiles all assets to the production directory `htdocs/luci-static/`, whic
|
||||
```
|
||||
htdocs/luci-static/
|
||||
├── aurora/
|
||||
│ ├── main.css # Minified CSS (via lightningcss)
|
||||
│ ├── main.css # Minified admin UI CSS (via lightningcss)
|
||||
│ ├── login.css # Minified login page CSS (via lightningcss)
|
||||
│ ├── fonts/ # Web fonts (Lato)
|
||||
│ └── images/ # Logo assets + PWA icons
|
||||
└── resources/
|
||||
@ -159,7 +174,7 @@ htdocs/luci-static/
|
||||
|
||||
**Build Process:**
|
||||
|
||||
1. Vite builds CSS entry point (`src/media/main.css`)
|
||||
1. Vite builds the CSS entry points (`src/media/main.css` and `src/media/login.css`)
|
||||
2. Custom PostCSS plugin removes `@layer` at-rules for OpenWrt compatibility
|
||||
3. Custom Vite plugin (`luci-js-compress`) minifies JS files via Terser
|
||||
4. Static assets copied from `.dev/public/aurora/`
|
||||
@ -175,17 +190,22 @@ htdocs/luci-static/
|
||||
|
||||
**Build `.ipk`/`.apk` packages:**
|
||||
|
||||
1. Push a version tag (`v*`) or push to `master` with `[build]` in the commit message
|
||||
2. The `build-theme-package` workflow compiles the OpenWrt package
|
||||
1. Push a version tag (`v*`), push to `master`/`feat/**` with `[build]` in the commit message, or manually trigger the workflow
|
||||
2. The `build-theme-package` workflow compiles both `.ipk` and `.apk` OpenWrt packages
|
||||
|
||||
**PR checks:**
|
||||
**PR review:**
|
||||
|
||||
Pull requests that touch `.dev/`, `htdocs/`, `ucode/`, or `root/` are automatically linted and build-verified by the `pr-check` workflow.
|
||||
Pull requests that touch `.dev/`, `htdocs/`, `ucode/`, or `root/` are automatically reviewed by the `claude-pr-review` workflow — it posts inline comments on the source diff (generated `htdocs/` output is excluded) plus a summary comment. Mention `@claude` in a PR comment to request a follow-up review or ask a question.
|
||||
|
||||
**Issue triage:**
|
||||
|
||||
New issues are handled by the `claude-issue-bot` workflow — it checks for spam/duplicates, applies labels, and posts a deep technical analysis comment. Mention `@claude` in an issue comment to get a response.
|
||||
|
||||
**Workflow Files:** `.github/workflows/`
|
||||
- `frontend-assets-build.yml` — Build assets and auto-commit
|
||||
- `frontend-assets-build.yml` — Build assets and auto-commit (manual trigger)
|
||||
- `build-theme-package.yml` — Compile `.ipk`/`.apk` packages
|
||||
- `pr-check.yml` — Lint and build verification for PRs
|
||||
- `claude-pr-review.yml` — AI code review for PRs (inline + summary comments)
|
||||
- `claude-issue-bot.yml` — AI issue triage and analysis
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@ -202,8 +222,15 @@ luci-theme-aurora/
|
||||
│ │ └── clean.js # Build cleanup utility
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── assets/icons/ # SVG icons
|
||||
│ │ ├── media/ # CSS entry points
|
||||
│ │ │ └── main.css # Main stylesheet (Tailwind CSS)
|
||||
│ │ ├── media/ # CSS source (Tailwind CSS v4)
|
||||
│ │ │ ├── main.css # Admin UI entry point (import manifest)
|
||||
│ │ │ ├── login.css # Login page entry point
|
||||
│ │ │ ├── _tokens.css # OKLCH theme tokens (@theme inline)
|
||||
│ │ │ ├── _base.css # Base element styles
|
||||
│ │ │ ├── _layout.css # Page layout/structure
|
||||
│ │ │ ├── _utilities.css # Custom utility classes
|
||||
│ │ │ ├── _patches.css # Third-party LuCI app/page overrides
|
||||
│ │ │ └── components/ # One partial per UI component
|
||||
│ │ └── resource/ # JavaScript resources
|
||||
│ │ └── menu-aurora.js # Menu logic
|
||||
│ ├── .env.example # Environment variables template
|
||||
@ -221,7 +248,8 @@ luci-theme-aurora/
|
||||
│ ├── aurora/ # Theme CSS and assets
|
||||
│ │ ├── fonts/ # Built font files
|
||||
│ │ ├── images/ # Built images + PWA icons
|
||||
│ │ └── main.css # Compiled CSS
|
||||
│ │ ├── main.css # Compiled admin UI CSS
|
||||
│ │ └── login.css # Compiled login page CSS
|
||||
│ └── resources/ # Built JavaScript modules
|
||||
│ └── menu-aurora.js # Minified menu logic
|
||||
├── root/etc/uci-defaults/ # OpenWrt system integration
|
||||
@ -240,8 +268,10 @@ luci-theme-aurora/
|
||||
|
||||
- **[Tailwind CSS v4](https://tailwindcss.com/)** - Utility-first CSS framework
|
||||
- **[Vite](https://vitejs.dev/)** - Build tool and development server
|
||||
**[pnpm](https://pnpm.io/)** - Fast, disk space efficient package manager
|
||||
- **[pnpm](https://pnpm.io/)** - Fast, disk space efficient package manager
|
||||
- **[lightningcss](https://lightningcss.dev/)** - CSS minifier
|
||||
- **[Terser](https://terser.org/)** - JavaScript minifier
|
||||
- **[Prettier](https://prettier.io/)** - Code formatter
|
||||
- **[prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)** - Tailwind class sorting
|
||||
- **[tw-animate-css](https://github.com/Wombosvideo/tw-animate-css)** - Animation utilities for Tailwind CSS
|
||||
- **[tailwind-scrollbar](https://github.com/adoxography/tailwind-scrollbar)** - Custom scrollbar styling plugin
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/*
|
||||
PATCH LAYER (temporary)
|
||||
- Purpose: Quick fixes and app-specific overrides that may be removed at any time.
|
||||
- Scope: Keep selectors narrow and limited to known breakages only.
|
||||
- Migration: Promote stable rules to appropriate layers (e.g., components/plugins) or delete once fixed upstream.
|
||||
PLUGIN/PAGE PATCHES
|
||||
- Purpose: Compatibility overrides for third-party LuCI apps/pages that don't adapt to the theme.
|
||||
- Scope: Keep selectors narrow and limited to the specific app/page being patched.
|
||||
- Note: May need to be revisited when the corresponding plugin updates its markup/styles.
|
||||
*/
|
||||
|
||||
/* admin-network-network */
|
||||
@ -10,7 +10,21 @@
|
||||
@apply max-md:overflow-visible!;
|
||||
}
|
||||
|
||||
/* luci-app-filemanager */
|
||||
#file-manager-container {
|
||||
@apply overflow-auto;
|
||||
#status-bar {
|
||||
@apply bg-page-bg border-0;
|
||||
}
|
||||
}
|
||||
|
||||
/* luci-app-openclash */
|
||||
.diag-style {
|
||||
@apply max-md:grid max-md:grid-cols-1 max-md:gap-4;
|
||||
& > div {
|
||||
@apply flex flex-col items-center gap-2 max-md:w-full! max-md:gap-1;
|
||||
}
|
||||
}
|
||||
[data-page="admin-services-openclash-config"] {
|
||||
.sub_div {
|
||||
img {
|
||||
@ -26,6 +40,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* luci-app-statistics */
|
||||
[data-page="admin-statistics-graphs"] [data-plugin] img {
|
||||
@apply dark:hue-rotate-150 dark:invert;
|
||||
}
|
||||
|
||||
/* luci-mod-dashboard */
|
||||
.Dashboard {
|
||||
& > .section-content {
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
/* luci-app-openclash */
|
||||
.diag-style {
|
||||
@apply max-md:grid max-md:grid-cols-1 max-md:gap-4;
|
||||
& > div {
|
||||
@apply flex flex-col items-center gap-2 max-md:w-full! max-md:gap-1;
|
||||
}
|
||||
}
|
||||
|
||||
/* luci-app-filemanager */
|
||||
#file-manager-container {
|
||||
@apply overflow-auto;
|
||||
#status-bar {
|
||||
@apply bg-page-bg border-0;
|
||||
}
|
||||
}
|
||||
|
||||
/* luci-app-statistics */
|
||||
[data-page="admin-statistics-graphs"] [data-plugin] img {
|
||||
@apply dark:hue-rotate-150 dark:invert;
|
||||
}
|
||||
@ -38,5 +38,4 @@
|
||||
@import "./components/_tab.css";
|
||||
|
||||
@import "./_utilities.css";
|
||||
@import "./_plugins.css";
|
||||
@import "./_patches.css";
|
||||
|
||||
@ -19,12 +19,12 @@ No test suite or linter CLI. Formatting uses Prettier with format-on-save (`.vsc
|
||||
|
||||
**Dual-layer build**: source in `.dev/` → OpenWrt-compatible output in `htdocs/luci-static/`.
|
||||
|
||||
- `.dev/src/media/main.css` → `htdocs/luci-static/aurora/main.css` (TailwindCSS v4, lightningcss)
|
||||
- `.dev/src/media/main.css` and `.dev/src/media/login.css` → `htdocs/luci-static/aurora/main.css` and `login.css` (TailwindCSS v4, lightningcss)
|
||||
- `.dev/src/resource/*.js` → `htdocs/luci-static/resources/*.js` (Terser, no bundling)
|
||||
- `.dev/public/aurora/` → `htdocs/luci-static/aurora/` (copied as-is)
|
||||
- `ucode/template/themes/aurora/*.ut` — server-side templates, not processed by Vite
|
||||
|
||||
**CSS**: single entry point `.dev/src/media/main.css`. All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `:root` and mapped via `@theme inline`. `@layer` at-rules stripped by PostCSS plugin for OpenWrt compatibility.
|
||||
**CSS**: two Tailwind CSS v4 entry points in `.dev/src/media/` — `main.css` (admin UI; a pure import manifest over `_tokens.css`, `_base.css`, `_layout.css`, `components/*.css`, `_utilities.css`, `_patches.css`) and `login.css` (standalone login page). All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `_tokens.css` and mapped via `@theme inline`. `@layer` at-rules stripped by PostCSS plugin for OpenWrt compatibility. See `.dev/docs/DEVELOPMENT.md` for the full CSS file layout.
|
||||
|
||||
**JavaScript**: LuCI `E()` DOM API (not React/Vue). Minified but not bundled.
|
||||
|
||||
@ -35,5 +35,5 @@ No test suite or linter CLI. Formatting uses Prettier with format-on-save (`.vsc
|
||||
## Key References
|
||||
|
||||
- **Development guide**: `.dev/docs/DEVELOPMENT.md` — dev server setup, env config, proxy details, CI workflows, directory structure
|
||||
- **Vite config**: `.dev/vite.config.ts` — custom plugins (luci-js-compress, local-serve, ut-sync, remove-layers)
|
||||
- **Vite config**: `.dev/vite.config.ts` — custom plugins (luci-js-compress, local-serve, redirect, ut-sync, remove-layers)
|
||||
- **Version**: `PKG_VERSION` and `PKG_RELEASE` in `Makefile`
|
||||
|
||||
@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
|
||||
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
|
||||
LUCI_DEPENDS:=+luci-base
|
||||
|
||||
PKG_VERSION:=0.12.3
|
||||
PKG_RELEASE:=20
|
||||
PKG_VERSION:=0.12.4
|
||||
PKG_RELEASE:=21
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
|
||||
LUCI_MINIFY_CSS:=
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user