File size: 7,637 Bytes
1e7a182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# ruff: noqa: E402
import asyncio
import logging
import re
import sys
import time
from pathlib import Path

from dotenv import load_dotenv

PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

load_dotenv(Path(__file__).resolve().parents[1] / ".env")
from datetime import datetime

from app.db.models import AIDetection, GroupConfig, MediaAsset, MessageEvent, StatsMessagesDaily, StatsWordcloudTerm
from app.db.session import SessionLocal
from app.services.ai_client import detect_image, detect_text
from app.services.queue import claim_job, complete_job, fail_job, get_pending_topics
from app.services.telegram_moderator import delete_message, punish_user
from app.services.telegram_sender import send_broadcast, send_post, send_scheduled_message

logger = logging.getLogger(__name__)

QUEUE_TOPICS = [
    "ai_text_detect",
    "ai_image_detect",
    "moderation_action",
    "broadcast_send",
    "post_send",
    "schedule_send",
    "schedule_tick",
    "stats_rollup",
]


async def _handle_ai_detection(message_id: str, kind: str) -> None:
    db = SessionLocal()
    try:
        message = db.get(MessageEvent, message_id)
        if not message:
            return
        config = db.query(GroupConfig).filter(GroupConfig.group_id == message.group_id).first()
        ad_cfg = (config.config_json.get("ad_filter") if config else {}) or {}
        if kind == "text" and not ad_cfg.get("text_ai_enabled", True):
            return
        if kind == "image" and not ad_cfg.get("image_ai_enabled", True):
            return
        threshold = float(ad_cfg.get("text_ai_threshold" if kind == "text" else "image_ai_threshold", 0.85))
        score = 0.0
        if kind == "text":
            score = await detect_text(message.text or "")
        else:
            file_id = ""
            if message.media_id:
                asset = db.get(MediaAsset, message.media_id)
                if asset:
                    file_id = asset.telegram_file_id
            score = await detect_image(file_id)
        result = "pass" if score < threshold else "reject"
        db.add(
            AIDetection(
                group_id=message.group_id,
                message_id=message.id,
                type=kind,
                score=score,
                threshold=threshold,
                model_id=None,
                result=result,
            )
        )
        db.commit()
        if result == "reject" and message.chat_id:
            action = ad_cfg.get("action", "delete")
            duration = ad_cfg.get("action_duration")
            if action == "delete":
                await delete_message(
                    chat_id=message.chat_id,
                    message_id=message.telegram_message_id,
                    group_id=str(message.group_id),
                    user_id=message.telegram_user_id,
                    reason="AI广告过滤",
                    source="ai",
                )
            else:
                await punish_user(
                    chat_id=message.chat_id,
                    user_id=message.telegram_user_id,
                    group_id=str(message.group_id),
                    action=action,
                    duration=duration,
                    reason="AI广告过滤",
                    source="ai",
                )
    finally:
        db.close()


def _tokenize(text: str) -> list[str]:
    tokens = re.findall(r"[A-Za-z0-9]+|[\u4e00-\u9fff]+", text)
    return [token for token in tokens if len(token) >= 2]


def _handle_stats_rollup(message_id: str) -> None:
    db = SessionLocal()
    try:
        message = db.get(MessageEvent, message_id)
        if not message:
            return
        day = (message.created_at or datetime.utcnow()).date()
        stats = (
            db.query(StatsMessagesDaily)
            .filter(StatsMessagesDaily.group_id == message.group_id, StatsMessagesDaily.date == day)
            .first()
        )
        if not stats:
            stats = StatsMessagesDaily(group_id=message.group_id, date=day, message_count=0, active_users=0)
            db.add(stats)
        stats.message_count += 1
        active_users = (
            db.query(MessageEvent.telegram_user_id)
            .filter(MessageEvent.group_id == message.group_id)
            .filter(MessageEvent.created_at >= datetime.combine(day, datetime.min.time()))
            .filter(MessageEvent.created_at <= datetime.combine(day, datetime.max.time()))
            .distinct()
            .count()
        )
        stats.active_users = active_users

        if message.text:
            for token in _tokenize(message.text):
                term = (
                    db.query(StatsWordcloudTerm)
                    .filter(
                        StatsWordcloudTerm.group_id == message.group_id,
                        StatsWordcloudTerm.date == day,
                        StatsWordcloudTerm.term == token,
                    )
                    .first()
                )
                if not term:
                    term = StatsWordcloudTerm(group_id=message.group_id, date=day, term=token, count=0)
                    db.add(term)
                term.count += 1
        db.commit()
    finally:
        db.close()


async def _dispatch(topic: str, payload: dict) -> None:
    """Dispatch a single queue message by topic."""
    if topic == "post_send":
        post_id = payload.get("post_id")
        if post_id:
            await send_post(post_id)
    elif topic == "broadcast_send":
        broadcast_id = payload.get("broadcast_id")
        if broadcast_id:
            await send_broadcast(broadcast_id)
    elif topic == "schedule_send":
        schedule_id = payload.get("schedule_id")
        if schedule_id:
            await send_scheduled_message(schedule_id)
    elif topic == "ai_text_detect":
        message_id = payload.get("message_id")
        if message_id:
            await _handle_ai_detection(message_id, "text")
    elif topic == "ai_image_detect":
        message_id = payload.get("message_id")
        if message_id:
            await _handle_ai_detection(message_id, "image")
    elif topic == "stats_rollup":
        message_id = payload.get("message_id")
        if message_id:
            # stats_rollup is sync (DB only), run in thread to avoid blocking
            await asyncio.to_thread(_handle_stats_rollup, message_id)
    else:
        logger.info("[worker] %s %s", topic, payload)


async def worker_loop() -> None:
    logger.info("worker started (PostgreSQL queue)")
    while True:
        try:
            pending_topics = await asyncio.to_thread(get_pending_topics, QUEUE_TOPICS)
            if not pending_topics:
                await asyncio.sleep(0.5)
                continue

            for topic in pending_topics:
                job = await asyncio.to_thread(claim_job, topic)
                if not job:
                    continue
                try:
                    await _dispatch(job.topic, job.payload)
                    await asyncio.to_thread(complete_job, job.id)
                except Exception:
                    logger.exception("worker error processing %s", job.topic)
                    await asyncio.to_thread(fail_job, job.id)
        except Exception:
            logger.exception("worker loop error")
            await asyncio.sleep(1)


def main():
    """Standalone entry point (backward compatible)."""
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    asyncio.run(worker_loop())


if __name__ == "__main__":
    main()