feat(sub): add dynamic remark variables with Jalali date, transport, and status tokens (#5430)

* feat(sub): implement dynamic single-bracket remark variables with timezone-aware inline Jalali conversion

* Update .gitignore

* Update .gitignore

* merge: bring in origin/main commits to resolve conflict base

* fix(sub): address review issues in dynamic remark variables

- Add TIME_LEFT to unlimitedDropTokens so segments containing only
  {TIME_LEFT} are dropped for unlimited clients (same as DAYS_LEFT)
- Remove dead uiSingleBraceRe variable (translateUISingleBrackets uses
  a character scanner, not this regex)
- Change expireDateLabel to use time.Local instead of UTC, consistent
  with jalaliExpireDateLabel

Co-authored-by: Sanaei <MHSanaei@users.noreply.github.com>

* fix

* fix

---------

Co-authored-by: MHSanaei <MHSanaei@users.noreply.github.com>
This commit is contained in:
wahh3b-lgtm
2026-06-21 03:30:27 +03:30
committed by GitHub
parent ce1d348ece
commit 605e90dbf0
11 changed files with 447 additions and 56 deletions
+181 -15
View File
@@ -37,7 +37,6 @@ func TestExpandRemarkVars(t *testing.T) {
cases := []struct{ tmpl, want string }{
{"{{EMAIL}}", "john@example.com"},
{"{{USERNAME}}", "john@example.com"},
{"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
{"{{HOST}}", ""}, // no host remark in ctx → empty
{"{{ID}}", client.ID},
@@ -169,10 +168,10 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
// never substitutes it (it is reachable only through {{HOST}}).
func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
s, inbound, client := hostRemarkService("") // no template → config name only
if got := s.genHostRemark(inbound, client, "Relay"); got != "DE" {
if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" {
t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
}
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
}
}
@@ -182,17 +181,17 @@ func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
func TestGenHostRemark_GlobalTemplate(t *testing.T) {
// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
if got := s.genHostRemark(inbound, client, "CDN"); got != "DE | 80.00GB | 10d" {
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE | 80.00GB | 10d" {
t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
}
// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
if got := s2.genHostRemark(inbound2, client2, "CDN"); got != "DE|CDN|80.00GB" {
if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE|CDN|80.00GB" {
t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
}
// {{HOST}} is the host's own remark even when the inbound has one of its own.
s3, inbound3, client3 := hostRemarkService("{{HOST}}")
if got := s3.genHostRemark(inbound3, client3, "CDN"); got != "CDN" {
if got := s3.genHostRemark(inbound3, client3, "CDN", ""); got != "CDN" {
t.Fatalf("{{HOST}} token = %q, want CDN", got)
}
}
@@ -201,7 +200,7 @@ func TestGenHostRemark_GlobalTemplate(t *testing.T) {
// legacy externalProxy remark passed as extra.
func TestGenRemark_GlobalTemplate(t *testing.T) {
s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
got := s.genRemark(inbound, "john@example.com", "")
got := s.genRemark(inbound, "john@example.com", "", "")
if got != "john@example.com | 80.00GB" {
t.Fatalf("global template (non-host) = %q", got)
}
@@ -210,7 +209,7 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
// With no template, genRemark composes the fallback model and adds no suffix.
func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
s, inbound, _ := hostRemarkService("")
got := s.genRemark(inbound, "john@example.com", "Relay")
got := s.genRemark(inbound, "john@example.com", "Relay", "")
if got != "DE-Relay" {
t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
}
@@ -220,8 +219,8 @@ func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
// link of the request; later links show the name-only template.
func TestUsageOnFirstLinkOnly(t *testing.T) {
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
first := s.genHostRemark(inbound, client, "")
second := s.genHostRemark(inbound, client, "")
first := s.genHostRemark(inbound, client, "", "")
second := s.genHostRemark(inbound, client, "", "")
if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
t.Fatalf("first link should carry usage: %q", first)
}
@@ -241,15 +240,15 @@ func TestRemarkInDisplayContext(t *testing.T) {
s.subscriptionBody = false
// A host link in a display shows only the config name — the inbound's remark,
// with no per-client email or usage info and the host remark ignored.
if got := s.genHostRemark(inbound, client, "CDN"); got != "DE" {
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
t.Fatalf("display host link = %q, want config name %q", got, "DE")
}
// With no host remark, the config name is likewise the inbound's own remark.
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
}
// genRemark (non-host) likewise drops the template in display context.
if got := s.genRemark(inbound, client.Email, ""); got != "DE" {
if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
t.Fatalf("display genRemark = %q, want %q", got, "DE")
}
}
@@ -294,9 +293,176 @@ func TestStatsForClient_CrossInboundFallback(t *testing.T) {
func TestGenHostRemark_PerClient(t *testing.T) {
s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
inbound := &model.Inbound{}
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "")
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "")
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "", "")
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "", "")
if a != "alice@x" || b != "bob@x" {
t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
}
}
func TestStatusEmoji(t *testing.T) {
cases := []struct {
stats xray.ClientTraffic
want string
}{
{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: gb}, "✅"},
{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: 10 * gb, Down: 1}, "🚫"},
{xray.ClientTraffic{Enable: false}, "🚫"},
{xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "⏳"},
}
for _, c := range cases {
if got := statusEmoji(c.stats); got != c.want {
t.Errorf("statusEmoji(%+v) = %q, want %q", c.stats, got, c.want)
}
}
}
func TestUsagePercentage(t *testing.T) {
if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
t.Errorf("usagePercentage 50%% = %q", got)
}
if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
t.Errorf("usagePercentage unlimited = %q, want empty", got)
}
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
t.Errorf("usagePercentage 100%% = %q", got)
}
// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
t.Errorf("usagePercentage over-quota = %q, want 100.0%%", got)
}
}
func TestTimeLeftLabel(t *testing.T) {
if got := timeLeftLabel(0); got != "∞" {
t.Errorf("timeLeftLabel(0) = %q, want ∞", got)
}
// Delayed-start: negative expiry = duration in ms. 1000ms = 1 second = "0m".
if got := timeLeftLabel(-1000); got != "0m" {
t.Errorf("timeLeftLabel(-1000) = %q, want 0m", got)
}
}
func TestGregorianToJalali(t *testing.T) {
cases := []struct {
gy, gm, gd int
jy, jm, jd int
}{
{2024, 1, 1, 1402, 10, 11},
{2000, 3, 20, 1379, 1, 1},
{1979, 2, 11, 1357, 11, 22},
}
for _, c := range cases {
jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
if jy != c.jy || jm != c.jm || jd != c.jd {
t.Errorf("gregorianToJalali(%d,%d,%d) = (%d,%d,%d), want (%d,%d,%d)",
c.gy, c.gm, c.gd, jy, jm, jd, c.jy, c.jm, c.jd)
}
}
}
func TestJalaliExpireDateLabel(t *testing.T) {
if got := jalaliExpireDateLabel(0); got != "" {
t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got)
}
if got := jalaliExpireDateLabel(-1000); got != "" {
t.Errorf("jalaliExpireDateLabel(-1000) = %q, want empty", got)
}
}
func TestExpandNewTokensInTemplate(t *testing.T) {
inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
client := model.Client{Email: "alice@test.com", ID: "abc-123"}
stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
ctx := remarkContext{
client: client,
stats: stats,
inbound: inbound,
transport: "ws",
}
cases := []struct{ tmpl, want string }{
{"{{STATUS_EMOJI}}", "✅"},
{"{{USAGE_PERCENTAGE}}", "50.0%"},
{"{{PROTOCOL}}", "VLESS"},
{"{{TRANSPORT}}", "ws"},
{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
}
for _, c := range cases {
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
}
}
}
func TestTranslateUISingleBrackets(t *testing.T) {
cases := []struct{ in, want string }{
{"{EMAIL}", "{{EMAIL}}"},
{"{DATA_LEFT}", "{{TRAFFIC_LEFT}}"},
{"{DATA_LEFT} of {DATA_LIMIT}", "{{TRAFFIC_LEFT}} of {{TRAFFIC_TOTAL}}"},
{"{STATUS_EMOJI} {INBOUND}", "{{STATUS_EMOJI}} {INBOUND}"},
{"{UNKNOWN_TOKEN}", "{UNKNOWN_TOKEN}"},
{"no braces", "no braces"},
{"{{TRAFFIC_LEFT}}", "{{TRAFFIC_LEFT}}"},
{"{username}", "{username}"},
}
for _, c := range cases {
if got := translateUISingleBrackets(c.in); got != c.want {
t.Errorf("translateUISingleBrackets(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
ctx := remarkContext{
client: model.Client{Email: "alice@test.com"},
stats: stats,
inbound: inbound,
transport: "ws",
}
cases := []struct{ tmpl, want string }{
{"{EMAIL}", "alice@test.com"},
{"{DATA_LEFT}", "50.00GB"},
{"{DATA_USAGE}", "50.00GB"},
{"{DATA_LIMIT}", "100.00GB"},
{"{STATUS_EMOJI}", "✅"},
{"{USAGE_PERCENTAGE}", "50.0%"},
{"{PROTOCOL}", "VLESS"},
{"{TRANSPORT}", "ws"},
}
for _, c := range cases {
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
}
}
}
func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
s := &SubService{
remarkTemplate: "{STATUS_EMOJI} {{INBOUND}}|📊{{TRAFFIC_LEFT}}",
subscriptionBody: true,
usageShown: map[string]bool{},
}
inbound := &model.Inbound{
Remark: "DE",
ClientStats: []xray.ClientTraffic{{
Email: "alice@x",
Enable: true,
Total: 100 * gb,
Up: 20 * gb,
Down: 10 * gb,
}},
}
client := model.Client{Email: "alice@x"}
first := s.genTemplatedRemark(inbound, client, "", "ws")
s.usageShown["alice@x"] = true
second := s.genTemplatedRemark(inbound, client, "", "ws")
if !strings.Contains(first, "📊") {
t.Fatalf("first link should carry usage: %q", first)
}
if strings.Contains(second, "📊") {
t.Fatalf("second link must not carry usage: %q", second)
}
}