Russ534

tg-signer-task-management

Add TG bot sign-in cron tasks to tg-signer on fnOS.

Russ534 0 Updated 4d ago
GitHub

Install

npx skillscat add russ534/myopenclawbot/tg-signer-task-management

Install via the SkillsCat registry.

SKILL.md

Adding a new TG sign-in task to tg-signer on fnOS

When to use

  • User says "add a TG sign-in task for @" or "another bot to sign in for"
  • User says "the new task isn't running on cron" — diagnose via the worker command pitfall
  • User wants to inspect or modify a tg-signer task without using the webui UI
  • Debugging a tg-signer cron that doesn't fire / doesn't show in docker logs

Cron-monitor hygiene — silent by default, ask before pushing

If you add a scheduled cronjob_manage task that delivers a digest/report (e.g. "daily tg-signer status check"), default to deliver: 'local' — save the output to ~/./cron/output/ only. Pushing to telegram / weixin / any chat MUST be opt-in.

This applies to ANY monitoring/digest job you create on the user's behalf:

  • Daily / hourly digests of a system that "mostly works" → local-only. The user will see the file; you do not push unsolicited summaries.
  • Failure-only alerts → push is justified, but confirm with the user first: "失败时推到这里,OK 静默对吗?" Push the failure-only pattern with verification_warn, not a verbose daily recap.
  • Recurring tasks that touch the user's own data (sign-in records, app status, container health) → ask before push. The user has stated preferences here ("正常了就不用监控了啊" = "when it's normal, stop monitoring"); a daily "all good" message IS noise, not signal.

When creating a cron job, include in the agent-visible description that the user can stop it:

To stop or manage this job, send me a new message (e.g. "stop reminder <name>").

The trap to avoid: cron jobs created on Day 1 to "monitor X" outlive their usefulness by Day 7 — the user expects "stable" to mean "quiet." If they say "正常了就不用监控了" / "stop the daily check" / "别再推了", DELETE the job (cronjob_manage action=remove), don't just pause it. A paused job still appears in cronjob_manage list and can be re-enabled by accident.

Self-check before declaring a cron job done: list it (cronjob_manage action=list), confirm deliver is local unless the user opted into push, and confirm there's a "how to stop" pointer in the prompt or delivery. If you find an old job with deliver: telegram that the user has forgotten about, surface it and ask whether to remove.

The four-step loop

Workflow for adding a second (or Nth) TG bot sign-in to an existing tg-signer deployment. Covers the full chain: discover the bot's chat_id and button layout → write config JSON → update worker container command → verify cron triggers. Same patterns apply to any task-based scheduler (tg-signer, media-automator, OneForAll style) where tasks live in a config directory and a single worker process handles cron triggers.

Step 1 — Discover the bot's chat_id and button structure (probe pattern)

Do not guess the chat_id or button text. The TG bot responds to /start with a Message containing an InlineKeyboard whose buttons have both text (display label, often emoji-prefixed like 🎯 签到) and callback_data (the action token). tg-signer's CLICK_KEYBOARD_BY_TEXT matches by text; CLICK_KEYBOARD_BY_DATA matches by callback_data. You need both pieces of data to fill the config correctly.

# /opt/data/cache/scratch/probe_tg_bot.py
import asyncio
from pyrogram import Client

async def probe(bot_username: str):
    client = Client(
        name="/opt/tg-signer/my_account",
        workdir="/opt/tg-signer",
        proxy=dict(scheme="socks5", hostname="192.168.31.201", port=7890)
    )
    await client.start()
    try:
        bot = await client.get_users(bot_username)
        print(f"id={bot.id} username=@{bot.username} name={bot.first_name}")
        await client.send_message(bot.id, "/start")
        await asyncio.sleep(3)
        async for msg in client.get_chat_history(bot.id, limit=3):
            who = "me" if (msg.from_user and msg.from_user.is_self) else "bot"
            print(f"\n--- {who} ---")
            if msg.text:    print(f"text: {msg.text[:300]}")
            if msg.caption: print(f"caption: {msg.caption[:300]}")
            if msg.reply_markup:
                if msg.reply_markup.inline_keyboard:
                    for row in msg.reply_markup.inline_keyboard:
                        for btn in row:
                            print(f"  text={btn.text!r}  cb={getattr(btn,'callback_data',None)!r}")
        print(f"\nchat_id = {bot.id}")
    finally:
        await client.stop()

asyncio.run(probe("your_bot_username"))

Run via:

scp /opt/data/cache/scratch/probe_tg_bot.py fnos:/tmp/probe.py
ssh fnos 'docker cp /tmp/probe.py <webui_container>:/tmp/probe.py'
ssh fnos 'docker exec <webui_container> python3 /tmp/probe.py'

(use the webui container, not the worker — webui is the one that has access to the shared data/ volume at /opt/tg-signer. Worker is fine too, but webui is the canonical "live but not yet running cron" container.)

Why not just call tg-signer send-text? It requires the chat_id to be passed in — circular. Probing via pyrogram from inside the container uses the existing my_account.session file (no new login flow) and gives you both chat_id AND the inline button structure in one shot.

Step 2 — Write the task config JSON

tg-signer stores tasks as JSON files, not in SQLite:

/vol1/1000/Docker/tg-signer/data/.signer/signs/<task_name>/config.json

Schema (minimal working example):

{
  "chats": [
    {
      "chat_id": 8544662605,
      "message_thread_id": null,
      "name": "JerryPorn (@jerryporn_bot)",
      "delete_after": null,
      "actions": [
        {"action": 1, "text": "/start"},
        {"action": 3, "text": "🎯 签到"}
      ],
      "action_interval": 1.0
    }
  ],
  "sign_at": "5 1 * * *",
  "random_seconds": 300,
  "sign_interval": 1
}

Action codes (from tg_signer.core.SupportAction enum):

code action use
1 SEND_TEXT Send a text message (/start, /login, etc.)
2 (reserved)
3 CLICK_KEYBOARD_BY_TEXT Click an inline/reply keyboard button matching the text field
4 CLICK_KEYBOARD_BY_DATA Match by callback_data instead — use when button text is dynamic or has emoji that varies

If your bot uses a multi-step flow (e.g. sign-in → "I'm not a robot" → captcha), append additional actions to the same actions array. action_interval (seconds) is the wait between consecutive actions — default 1.0 is fine for /start → button click.

Cron (sign_at): 5-field cron expression. Default 0 1 * * * = daily 01:00. 5 1 * * * = daily 01:05 — use this to stagger multiple tasks so they don't race for the pyrogram session at the same moment (see SKILL.md "Worker command must list ALL task names").

random_seconds: 0-N second random jitter applied to each cron trigger. 300 (default) means actual trigger time is cron ± up to 300s. Don't reduce this below ~60 — multiple tg-signer instances pointing at the same bot will trigger rate-limiting if they're all at the same instant.

To create the config from inside the agent (avoid write_file parsing issues — write JSON via execute_code which validates):

import subprocess
script = '''
import json, os
cfg = {
    "chats": [{"chat_id": <ID>, "message_thread_id": None,
               "name": "<human readable>",
               "delete_after": None,
               "actions": [{"action": 1, "text": "/start"},
                          {"action": 3, "text": "<button text>"}],
               "action_interval": 1.0}],
    "sign_at": "<cron expr>",
    "random_seconds": 300,
    "sign_interval": 1
}
path = "/vol1/1000/Docker/tg-signer/data/.signer/signs/<task_name>/config.json"
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f: json.dump(cfg, f, ensure_ascii=False, indent=2)
'''
subprocess.run(["ssh", "fnos", f"python3 -c '{script}'"], check=True)

(sed escaping * in cron expressions is a recurring pain — '5 1 * * *' passed through ssh heredoc / sed loses the asterisks. JSON config avoids that entirely.)

Step 3 — Update worker container command (THE CRITICAL STEP)

services:
 tg-signer: # worker
 command: ["tg-signer", "run", "my_sign", "jerryporn_sign"] # ← list ALL tasks

tg-signer run <X> accepts multiple task names. Each task's cron triggers independently. If the command only lists ["my_sign"], then jerryporn_sign will be silently ignored — no error, no log line, the worker just never loads it.

Tell-tale symptom of missing this step: docker logs tg-signer shows lines like 任务「my_sign」: 下次运行时间: 2026-09-28 01:01:24+08:00 but zero lines for jerryporn_sign. The data file exists, webui shows the task configured, but cron never fires.

Apply the change:

# Update compose command on the host
ssh fnos 'python3 -c "
import yaml
data = yaml.safe_load(open(\"/vol1/1000/Docker/tg-signer/docker-compose.yml\"))
data[\"services\"][\"tg-signer\"][\"command\"] = [\"tg-signer\", \"run\", \"my_sign\", \"jerryporn_sign\"]
yaml.dump(data, open(\"/vol1/1000/Docker/tg-signer/docker-compose.yml\", \"w\"), default_flow_style=False, allow_unicode=True)
"'
ssh fnos 'cd /vol1/1000/Docker/tg-signer && docker compose up -d tg-signer' # recreate, NOT restart

Use docker compose up -d <service> (not docker restart) — env/command changes require recreate semantics, and restart may trigger Hermes审核's destructive-op gate that up -d on a compose-changed service bypasses.

Step 4 — Verify (hard-evidence ladder)

docker ps showing Up is not enough. Walk the ladder:

Level 1: Worker logs show the new task loaded.

ssh fnos 'docker logs tg-signer --tail 50 2>&1 | grep -E "jerryporn_sign|任务「jerryporn"'

Expected: 账户「my_account」- 任务「jerryporn_sign」: 为以下Chat添加消息回调处理函数:[8544662605] followed by 下次运行时间: <future date>. If absent → Step 3 was missed or compose didn't recreate the container.

Level 2: Trigger manually now to confirm the action flow works end-to-end.

ssh fnos 'docker exec <webui_container> tg-signer run-once <task_name> 2>&1 | grep -E "SupportAction|点击|处理完成|签到成功|积分cc"'

Look for 处理完成: action=<SupportAction.CLICK_KEYBOARD_BY_TEXT: 3> text='🎯 签到'. If the bot replies with 签到成功 | X cc (level-up the bonus), the click landed correctly.

Level 3: Hardest evidence — DB row.

ssh fnos 'docker exec <webui_container> python3 -c "
import sqlite3, datetime
c = sqlite3.connect(\"/opt/tg-signer/.signer/data.sqlite3\")
cur = c.cursor()
today = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))).strftime(\"%Y-%m-%d\")
cur.execute(\"SELECT task_name, sign_date, signed_at, source FROM sign_records WHERE sign_date=? ORDER BY signed_at DESC\", (today,))
print(cur.fetchall())
"'

Expected row with source=runtime. If missing → either Step 1's chat_id is wrong, the button text doesn't match exactly (emoji codepoint matters!), or the bot has a per-day quota and today was already used by a manual sign-in (in which case tg-signer logs "点击完成" but bot doesn't reply with bonus).

Cron timezone gotcha

Hermes's cron runs in UTC. cronjob_manage schedule: "every day at 1:30am" parses as UTC 01:30, which is Beijing-time 09:30. If the user wants Beijing-time morning verification, the schedule must be UTC and offset by 8 hours.

For Beijing-time 01:30 → cron 17 * * * (UTC 17:30). For Beijing-time 10:00 → cron 0 2 * * * (UTC 02:00). Always sanity-check next_run_at from the cronjob creation response and cross-reference against date -u from a terminal in the same UTC zone.

The sign_at inside tg-signer's config.json is NOT a cron job in the hermes sense — it's parsed by tg-signer itself and the container runs in TZ=Asia/Shanghai, so 0 1 * * * inside that JSON really does mean Beijing-time 01:00. Different from hermes cron jobs. Don't conflate.

Webui vs worker config parity

Both containers mount the same data/ volume, so task configs written directly to disk are visible in webui. But don't write config files while webui is open in a browser tab editing the same task — webui's save_config overwrites the JSON on every save, and a stale concurrent write from the agent can clobber user edits in webui. For agent-driven updates, do it via the JSON path; for human-driven updates, do it via webui. Don't mix.

Common mistakes (do not repeat)

  • Leaving worker command pinned to ["tg-signer", "run", "my_sign"] when adding a second task. Silent. No error. Just no logs for the new task. (Hit on 2026-09-27.)
  • Forgetting that docker compose up -d <worker> after editing the command fires every listed task's action flow ONCE at startup, regardless of cron schedule — Recreate is treated as "process just booted, do a smoke run". If you recreate at 18:22 and the new task is 5 01 * * *, the bot will be clicked today at 18:22 (consuming today's daily-quota if the bot enforces one) and again at 01:05 tomorrow. So you can't use "recreate + watch logs" as a clean verification step if the bot has a once-per-day cap — the recreate itself is a real sign-in attempt. Schedule the recreate at a moment that won't double-fire (or accept the double-fire if the bot allows multiple clicks per day).
  • Using CLICK_KEYBOARD_BY_TEXT with text containing variation selectors / zero-width joiners. Some bots prefix buttons with U+FE0F (variation selector-16) that copy-paste loses. If the click "completes" but bot doesn't reply with bonus, dump the button text exactly via the probe and compare codepoint-by-codepoint (hex() on each char).
  • Confusing the bot's reply "🎉 签到成功 | X cc" with tg-signer's own log "签到成功". tg-signer logs "处理完成" after every action regardless of whether the bot accepted the click. The only way to know if the external action succeeded is the bot's reply text in get_chat_history (Level 2/3 evidence).
  • Forgetting to start the webui container's bash with proxy envs. The probe script accesses api.telegram.org directly — without TG_PROXY=socks5://192.168.31.201:7890, getUsers/sendMessage hang on DNS resolution. The container's docker exec inherits compose envs, so as long as compose sets them, you're fine.

Related references

  • fnnas-admin / references/deploy-new-container-on-fnos.md — full tg-signer first-deploy walkthrough
  • fnnas-admin / SKILL.md "Splitting worker + webui into two containers" — the base compose template
  • fnnas-admin / SKILL.md "Worker command must list ALL task names (multi-task scheduling)" — the always-on rule this reference extends