tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Sutichaykuna

⎇ 84d4ac3a07474bf1bcd21ee43b913b75d8b32180 consola/src/main/java/com/rizoma/consola/push/Alerts.kt 130 siq'ikuna · 5.3 KB Ñawpa willay ch'uya

consola: push en background vía UnifiedPush (ntfy) 84d4ac3a0 · Sergio ·

1package com.rizoma.consola.push
2
3import android.app.NotificationChannel
4import android.app.NotificationManager
5import android.app.PendingIntent
6import android.content.Context
7import android.content.Intent
8import androidx.core.app.NotificationCompat
9import androidx.core.app.RemoteInput
10import com.rizoma.consola.MainActivity
11import com.rizoma.consola.R
12import com.rizoma.consola.service.ConsolaService
13import org.json.JSONObject
14
15/**
16 * Construye y publica las notificaciones de aviso de Claude. Lo usan los DOS
17 * caminos de entrega —el WebSocket de eventos (foreground) y el receptor
18 * UnifiedPush (background)—, así que vive fuera del servicio y **deduplica por
19 * el `id` que sella el gateway** para no mostrar el mismo aviso dos veces.
20 */
21object Alerts {
22    const val ACTION_REPLY = "com.rizoma.consola.action.REPLY"
23    const val ACTION_KEY = "com.rizoma.consola.action.KEY"
24    const val EXTRA_KEY = "key"
25    const val EXTRA_NOTIF_ID = "notif_id"
26    const val KEY_REPLY = "reply"
27
28    private const val CHANNEL_ID = "consola_alertas"
29    private const val ID_BASE = 0xA1E0
30    private const val NEON_VERDE = 0xFF7CFF4F.toInt()
31
32    private val seen = LinkedHashSet<String>()
33    private var nextId = ID_BASE
34
35    @Synchronized
36    private fun firstTime(id: String): Boolean {
37        if (id.isBlank()) return true // sin id: no podemos deduplicar, mostrar
38        if (!seen.add(id)) return false
39        if (seen.size > 100) seen.iterator().run { next(); remove() }
40        return true
41    }
42
43    @Synchronized
44    private fun allocId(): Int {
45        val n = nextId++
46        if (nextId > ID_BASE + 50) nextId = ID_BASE
47        return n
48    }
49
50    fun ensureChannel(ctx: Context) {
51        val mgr = ctx.getSystemService(NotificationManager::class.java)
52        if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
53            mgr.createNotificationChannel(
54                NotificationChannel(CHANNEL_ID, "avisos de Claude", NotificationManager.IMPORTANCE_HIGH)
55                    .apply { description = "cuando un Claude pide permiso o termina una tarea" },
56            )
57        }
58    }
59
60    /** Parsea el evento JSON, deduplica por `id` y publica la notificación. */
61    fun show(ctx: Context, eventJson: String) {
62        val o = runCatching { JSONObject(eventJson) }.getOrNull() ?: return
63        if (!firstTime(o.optString("id"))) return
64
65        val kind = o.optString("kind")
66        val cwd = o.optString("cwd")
67        val msg = o.optString("message")
68        val session = o.optString("shuma_session").ifBlank { null }
69        val dir = cwd.substringAfterLast('/').ifBlank { cwd }
70        val (title, text) = when (kind) {
71            "Notification" ->
72                "Un Claude te necesita" to (msg.ifBlank { "esperando tu input" } + if (dir.isNotBlank()) "  ·  $dir" else "")
73            "Stop" ->
74                "Un Claude terminó" to (if (dir.isNotBlank()) "en $dir" else "tarea completada")
75            else ->
76                kind.ifBlank { "Claude" } to msg.ifBlank { dir }
77        }
78
79        ensureChannel(ctx)
80        val nid = allocId()
81
82        // Al tocar: abre la app y se adjunta a ESA sesión (si la sabemos).
83        val open = Intent(ctx, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
84        if (session != null) open.putExtra(MainActivity.EXTRA_SESSION, session)
85        val contentPending = PendingIntent.getActivity(
86            ctx, nid * 8, open,
87            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
88        )
89
90        val b = NotificationCompat.Builder(ctx, CHANNEL_ID)
91            .setSmallIcon(R.drawable.ic_consola)
92            .setContentTitle(title)
93            .setContentText(text)
94            .setColor(NEON_VERDE)
95            .setPriority(NotificationCompat.PRIORITY_HIGH)
96            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
97            .setAutoCancel(true)
98            .setContentIntent(contentPending)
99
100        // Acciones rápidas: solo si sabemos a qué sesión inyectar.
101        if (session != null) {
102            val reply = RemoteInput.Builder(KEY_REPLY).setLabel("responder a Claude…").build()
103            val replyPending = PendingIntent.getService(
104                ctx, nid * 8 + 1, serviceAction(ctx, ACTION_REPLY, session, nid),
105                PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
106            )
107            b.addAction(
108                NotificationCompat.Action.Builder(0, "responder", replyPending)
109                    .addRemoteInput(reply).build(),
110            )
111            b.addAction(0, "⏎ continuar", keyPending(ctx, session, "enter", nid, nid * 8 + 2))
112            b.addAction(0, "esc", keyPending(ctx, session, "esc", nid, nid * 8 + 3))
113        }
114
115        ctx.getSystemService(NotificationManager::class.java).notify(nid, b.build())
116    }
117
118    private fun serviceAction(ctx: Context, action: String, sessionId: String, nid: Int): Intent =
119        Intent(ctx, ConsolaService::class.java)
120            .setAction(action)
121            .putExtra(MainActivity.EXTRA_SESSION, sessionId)
122            .putExtra(EXTRA_NOTIF_ID, nid)
123
124    private fun keyPending(ctx: Context, sessionId: String, key: String, nid: Int, req: Int): PendingIntent =
125        PendingIntent.getService(
126            ctx, req,
127            serviceAction(ctx, ACTION_KEY, sessionId, nid).putExtra(EXTRA_KEY, key),
128            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
129        )
130}