The review of 2026-10-09 (M4): another holder of the token can change a message's words and leave its buttons, so the operator taps Release under words that say something else. Each message's words are now kept as a digest, per chat and message, and a tap on a message whose words differ, or that this channel never showed, is refused on the phone and never said to the router. The README says, as the operator directed (H5), that an approval from Telegram is meaningful only where agents run under their own account without sudo, without the operator's X display, and with Telegram Desktop's data readable by the operator alone; until then, approve from the phone.
134 lines
4.1 KiB
Go
134 lines
4.1 KiB
Go
// telegram: a channel of the operator's conversation, kind `telegram` of the `channel` and `intake` benches
|
|
// (novox/hq ADR 0234, ADR 0259). A Go bundle the node's runtime launches as this module's own account, never
|
|
// root and never the operator's: the bot token and this channel's state are that account's alone. stdout is
|
|
// the MCP channel; what this module says, it says on stderr.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
|
|
|
// Worker is the work queue of this kind, as the controller names it.
|
|
const Worker = "SEAT_CHANNEL_TELEGRAM_worker"
|
|
|
|
// busStore keeps the update offset and where each message is in this module's state on the bus.
|
|
type busStore struct{}
|
|
|
|
func (busStore) Offset() (int64, error) {
|
|
e, err := stdio.State("offset").Get("updates")
|
|
if err != nil || e == nil {
|
|
return 0, err
|
|
}
|
|
var o struct {
|
|
Next int64 `json:"next"`
|
|
}
|
|
return o.Next, json.Unmarshal(e.Value, &o)
|
|
}
|
|
func (busStore) SetOffset(next int64) error {
|
|
_, err := stdio.State("offset").Put("updates", map[string]int64{"next": next})
|
|
return err
|
|
}
|
|
func (busStore) Placed(handle string) (*Placed, error) {
|
|
e, err := stdio.State("messages").Get(handle)
|
|
if err != nil || e == nil {
|
|
return nil, err
|
|
}
|
|
var p Placed
|
|
return &p, json.Unmarshal(e.Value, &p)
|
|
}
|
|
func (busStore) Place(handle string, p Placed) error {
|
|
_, err := stdio.State("messages").Put(handle, p)
|
|
return err
|
|
}
|
|
|
|
// shownKey is where what a message shows is kept, beside the router's handles in the same state.
|
|
func shownKey(chat, id int64) string { return fmt.Sprintf("shown-words.%d.%d", chat, id) }
|
|
|
|
func (busStore) Shown(chat, id int64) (string, error) {
|
|
e, err := stdio.State("messages").Get(shownKey(chat, id))
|
|
if err != nil || e == nil {
|
|
return "", err
|
|
}
|
|
var w struct {
|
|
Digest string `json:"digest"`
|
|
}
|
|
return w.Digest, json.Unmarshal(e.Value, &w)
|
|
}
|
|
func (busStore) Show(chat, id int64, digest string) error {
|
|
_, err := stdio.State("messages").Put(shownKey(chat, id), map[string]string{"digest": digest})
|
|
return err
|
|
}
|
|
|
|
func main() {
|
|
c := &Channel{
|
|
Bot: &Bot{TokenFile: os.Getenv("MESH_TELEGRAM_TOKEN_FILE"), Client: &http.Client{Timeout: PollWait + 15*time.Second}},
|
|
Store: busStore{},
|
|
Publish: func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) },
|
|
Prove: stdio.SeatProve,
|
|
Logf: logf,
|
|
}
|
|
c.init()
|
|
go run(c)
|
|
tools := []stdio.Tool{{Name: "telegram_status",
|
|
Description: "Whether this channel can reach the operator on Telegram, and why not: whether the bot token works " +
|
|
"(never the token), the bot's name, when updates last arrived, the last delivery and the last error, how " +
|
|
"many it delivered and failed since it started, and the rules it keeps. Linking an account as the " +
|
|
"operator is the router's (messenger_identities).",
|
|
Run: func(map[string]any) (any, error) { return c.Status(), nil }}}
|
|
if err := stdio.Serve("", tools); err != nil {
|
|
logf("%v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// run takes this kind's work, long-polls the updates and says its standing — each retried, each failure said.
|
|
func run(c *Channel) {
|
|
time.Sleep(500 * time.Millisecond) // Serve first: the bus is reached through it
|
|
go func() {
|
|
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
|
err := stdio.SeatTake(Worker, func(w stdio.Work) error { return c.Take(w.Subject, w.Body) })
|
|
if err == nil {
|
|
logf("[telegram] taking the router's messages for this kind")
|
|
return
|
|
}
|
|
logf("[telegram] not taking messages yet (%v); asking again in %s", err, wait)
|
|
time.Sleep(wait)
|
|
}
|
|
}()
|
|
go func() {
|
|
for {
|
|
c.SayStanding()
|
|
time.Sleep(time.Minute)
|
|
}
|
|
}()
|
|
said := ""
|
|
for {
|
|
if _, err := c.Bot.token(); err != nil {
|
|
if err.Error() != said {
|
|
logf("[telegram] %v", err)
|
|
said = err.Error()
|
|
}
|
|
time.Sleep(time.Minute)
|
|
continue
|
|
}
|
|
if err := c.Poll(PollWait); err != nil {
|
|
if err.Error() != said {
|
|
logf("[telegram] the updates do not arrive: %v", err)
|
|
said = err.Error()
|
|
}
|
|
c.SayStanding()
|
|
time.Sleep(10 * time.Second)
|
|
continue
|
|
}
|
|
said = ""
|
|
}
|
|
}
|